geojson-diff 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geojson_diff/__init__.py +60 -0
- geojson_diff/__main__.py +7 -0
- geojson_diff/_shapely.py +80 -0
- geojson_diff/cli.py +374 -0
- geojson_diff/core.py +678 -0
- geojson_diff/geometry.py +454 -0
- geojson_diff/git.py +112 -0
- geojson_diff/io.py +254 -0
- geojson_diff/matching.py +218 -0
- geojson_diff/properties.py +208 -0
- geojson_diff/py.typed +0 -0
- geojson_diff/render/__init__.py +146 -0
- geojson_diff/render/html.py +237 -0
- geojson_diff/render/layer.py +111 -0
- geojson_diff/render/machine.py +20 -0
- geojson_diff/render/markdown.py +143 -0
- geojson_diff/render/text.py +85 -0
- geojson_diff/tolerance.py +86 -0
- geojson_diff-0.1.0.dist-info/METADATA +478 -0
- geojson_diff-0.1.0.dist-info/RECORD +24 -0
- geojson_diff-0.1.0.dist-info/WHEEL +5 -0
- geojson_diff-0.1.0.dist-info/entry_points.txt +3 -0
- geojson_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
- geojson_diff-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Output formats: text, JSON, Markdown, GeoJSON diff layer and HTML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..core import DiffResult, FeatureChange
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"FORMATS",
|
|
13
|
+
"describe_change",
|
|
14
|
+
"format_area",
|
|
15
|
+
"format_distance",
|
|
16
|
+
"format_value",
|
|
17
|
+
"render",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
FORMATS = ("text", "json", "md", "geojson", "html")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_value(value: Any, limit: int = 60) -> str:
|
|
24
|
+
"""Compact JSON rendering of a property value, truncated to ``limit`` characters."""
|
|
25
|
+
text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
26
|
+
if len(text) > limit:
|
|
27
|
+
text = text[: limit - 3] + "..."
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_distance(value: float | None, units: str = "m") -> str:
|
|
32
|
+
if value is None or not math.isfinite(value):
|
|
33
|
+
return "n/a"
|
|
34
|
+
if units != "m":
|
|
35
|
+
return f"{value:.4g} units"
|
|
36
|
+
if abs(value) >= 1000.0:
|
|
37
|
+
return f"{value / 1000.0:.3g} km"
|
|
38
|
+
if abs(value) >= 1.0:
|
|
39
|
+
return f"{value:.1f} m"
|
|
40
|
+
return f"{value:.3g} m"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _signed(text: str, value: float) -> str:
|
|
44
|
+
return ("+" if value > 0 else "") + text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_area(value: float, units: str = "m", signed: bool = False, sup: bool = False) -> str:
|
|
48
|
+
sq = "²" if sup else "2"
|
|
49
|
+
if units != "m":
|
|
50
|
+
text = f"{value:.4g} units{sq}"
|
|
51
|
+
elif abs(value) >= 1e6:
|
|
52
|
+
text = f"{value / 1e6:.3g} km{sq}"
|
|
53
|
+
elif abs(value) >= 1e4:
|
|
54
|
+
text = f"{value / 1e4:.3g} ha"
|
|
55
|
+
else:
|
|
56
|
+
text = f"{value:.1f} m{sq}"
|
|
57
|
+
return _signed(text, value) if signed else text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_length(value: float, units: str = "m", signed: bool = False) -> str:
|
|
61
|
+
text = format_distance(value, units)
|
|
62
|
+
return _signed(text, value) if signed else text
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def describe_change(change: FeatureChange, sup: bool = False, arrow: str = "->") -> list[str]:
|
|
66
|
+
"""Human-readable detail lines for one change (shared by all text formats)."""
|
|
67
|
+
lines: list[str] = []
|
|
68
|
+
for prop in change.properties:
|
|
69
|
+
if prop.op == "add":
|
|
70
|
+
lines.append(f"+ {prop.name}: {format_value(prop.new)}")
|
|
71
|
+
elif prop.op == "remove":
|
|
72
|
+
lines.append(f"- {prop.name}: {format_value(prop.old)}")
|
|
73
|
+
else:
|
|
74
|
+
line = f"{prop.name}: {format_value(prop.old)} {arrow} {format_value(prop.new)}"
|
|
75
|
+
if prop.type_changed:
|
|
76
|
+
line += f" ({prop.old_type} {arrow} {prop.new_type})"
|
|
77
|
+
lines.append(line)
|
|
78
|
+
geom = change.geometry
|
|
79
|
+
if geom is not None and geom.changed:
|
|
80
|
+
units = geom.units
|
|
81
|
+
if geom.type_changed:
|
|
82
|
+
lines.append(f"geometry type: {geom.old_type} {arrow} {geom.new_type}")
|
|
83
|
+
parts = []
|
|
84
|
+
if geom.hausdorff is not None:
|
|
85
|
+
parts.append(f"Hausdorff {format_distance(geom.hausdorff, units)}")
|
|
86
|
+
if geom.centroid_shift is not None:
|
|
87
|
+
parts.append(f"centroid shift {format_distance(geom.centroid_shift, units)}")
|
|
88
|
+
vertex = []
|
|
89
|
+
if geom.vertices_moved:
|
|
90
|
+
vertex.append(f"{geom.vertices_moved} moved")
|
|
91
|
+
if geom.vertices_added:
|
|
92
|
+
vertex.append(f"{geom.vertices_added} added")
|
|
93
|
+
if geom.vertices_removed:
|
|
94
|
+
vertex.append(f"{geom.vertices_removed} removed")
|
|
95
|
+
if vertex:
|
|
96
|
+
parts.append(
|
|
97
|
+
f"vertices {', '.join(vertex)} ({geom.vertices_old} {arrow} {geom.vertices_new})"
|
|
98
|
+
)
|
|
99
|
+
if geom.area_old or geom.area_new:
|
|
100
|
+
parts.append(f"area {format_area(geom.area_delta, units, True, sup)}")
|
|
101
|
+
if geom.length_old or geom.length_new:
|
|
102
|
+
parts.append(f"length {format_length(geom.length_delta, units, True)}")
|
|
103
|
+
if geom.symmetric_difference_area is not None:
|
|
104
|
+
parts.append(
|
|
105
|
+
f"symmetric difference {format_area(geom.symmetric_difference_area, units, sup=sup)}"
|
|
106
|
+
)
|
|
107
|
+
lines.append("geometry: " + ", ".join(parts))
|
|
108
|
+
if change.status in ("added", "removed"):
|
|
109
|
+
props = change.feature.get("properties") or {}
|
|
110
|
+
shown = [f"{k}: {format_value(v, 40)}" for k, v in list(props.items())[:3]]
|
|
111
|
+
if len(props) > 3:
|
|
112
|
+
shown.append(f"... {len(props) - 3} more")
|
|
113
|
+
lines.extend(shown)
|
|
114
|
+
return lines
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def render(result: DiffResult, fmt: str = "text", **kwargs: Any) -> str:
|
|
118
|
+
"""Render ``result`` as ``text``, ``json``, ``md``, ``geojson`` or ``html``."""
|
|
119
|
+
fmt = {"markdown": "md", "txt": "text", "htm": "html"}.get(fmt, fmt)
|
|
120
|
+
if fmt == "text":
|
|
121
|
+
from .text import render_text
|
|
122
|
+
|
|
123
|
+
return render_text(result, **kwargs)
|
|
124
|
+
if fmt == "json":
|
|
125
|
+
from .machine import render_json
|
|
126
|
+
|
|
127
|
+
return render_json(result, **kwargs)
|
|
128
|
+
if fmt == "md":
|
|
129
|
+
from .markdown import render_markdown
|
|
130
|
+
|
|
131
|
+
return render_markdown(result, **kwargs)
|
|
132
|
+
if fmt == "geojson":
|
|
133
|
+
from .layer import render_layer
|
|
134
|
+
|
|
135
|
+
return render_layer(result, **kwargs)
|
|
136
|
+
if fmt == "html":
|
|
137
|
+
from .html import render_html
|
|
138
|
+
|
|
139
|
+
return render_html(result, **kwargs)
|
|
140
|
+
raise ValueError(f"unknown format {fmt!r}; choose from {', '.join(FORMATS)}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def summary_counts(result: DiffResult) -> dict[str, int]:
|
|
144
|
+
return {
|
|
145
|
+
k: result.counts.get(k, 0) for k in ("added", "removed", "modified", "moved", "unchanged")
|
|
146
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Self-contained HTML report with a Leaflet before/after map."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..core import STATUSES, DiffResult
|
|
11
|
+
from . import describe_change, format_area, format_length
|
|
12
|
+
from .layer import STYLE, diff_layer
|
|
13
|
+
|
|
14
|
+
__all__ = ["DEFAULT_TILES", "OSM_ATTRIBUTION", "render_html", "script_json"]
|
|
15
|
+
|
|
16
|
+
LEAFLET_VERSION = "1.9.4"
|
|
17
|
+
_LEAFLET_JS = f"https://unpkg.com/leaflet@{LEAFLET_VERSION}/dist/leaflet.js"
|
|
18
|
+
_LEAFLET_CSS = f"https://unpkg.com/leaflet@{LEAFLET_VERSION}/dist/leaflet.css"
|
|
19
|
+
_LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
20
|
+
_LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
21
|
+
|
|
22
|
+
DEFAULT_TILES = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
23
|
+
OSM_ATTRIBUTION = (
|
|
24
|
+
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def script_json(value: Any) -> str:
|
|
29
|
+
"""JSON that is safe inside ``<script type="application/json">``.
|
|
30
|
+
|
|
31
|
+
``<``, ``>`` and ``&`` are written as Unicode escapes, so a value such as
|
|
32
|
+
``</script><script>alert(1)</script>`` cannot terminate the element.
|
|
33
|
+
"""
|
|
34
|
+
text = json.dumps(value, ensure_ascii=False, allow_nan=False, default=str)
|
|
35
|
+
return (
|
|
36
|
+
text.replace("&", "\\u0026")
|
|
37
|
+
.replace("<", "\\u003c")
|
|
38
|
+
.replace(">", "\\u003e")
|
|
39
|
+
.replace("
", "\\u2028")
|
|
40
|
+
.replace("
", "\\u2029")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_MARKER = re.compile("@@[A-Z_]+@@")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _e(value: Any) -> str:
|
|
48
|
+
return html.escape(str(value), quote=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_TEMPLATE = """<!DOCTYPE html>
|
|
52
|
+
<html lang="en">
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="utf-8">
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
56
|
+
<meta name="referrer" content="strict-origin-when-cross-origin">
|
|
57
|
+
<title>@@TITLE@@</title>
|
|
58
|
+
<link rel="stylesheet" href="@@LEAFLET_CSS@@" integrity="@@LEAFLET_CSS_SRI@@" crossorigin="">
|
|
59
|
+
<style>
|
|
60
|
+
:root { --fg:#1f2328; --muted:#59636e; --bg:#ffffff; --panel:#f6f8fa; --border:#d1d9e0; }
|
|
61
|
+
@media (prefers-color-scheme: dark) {
|
|
62
|
+
:root { --fg:#e6edf3; --muted:#9198a1; --bg:#0d1117; --panel:#151b23; --border:#3d444d; }
|
|
63
|
+
}
|
|
64
|
+
* { box-sizing: border-box; }
|
|
65
|
+
body { margin:0; font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; color:var(--fg); background:var(--bg); }
|
|
66
|
+
header, main { max-width:1200px; margin:0 auto; padding:16px; }
|
|
67
|
+
h1 { font-size:20px; margin:0 0 4px; word-break:break-all; }
|
|
68
|
+
.meta { color:var(--muted); }
|
|
69
|
+
.cards { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; }
|
|
70
|
+
.card { background:var(--panel); border:1px solid var(--border); border-radius:6px; padding:8px 12px; min-width:96px; }
|
|
71
|
+
.card b { display:block; font-size:22px; }
|
|
72
|
+
.swatch { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:6px; }
|
|
73
|
+
#map { height:480px; border:1px solid var(--border); border-radius:6px; }
|
|
74
|
+
.nomap { padding:24px; background:var(--panel); border:1px solid var(--border); border-radius:6px; }
|
|
75
|
+
.table-wrap { overflow-x:auto; }
|
|
76
|
+
table { border-collapse:collapse; width:100%; margin-top:16px; }
|
|
77
|
+
th, td { text-align:left; vertical-align:top; padding:6px 8px; border-bottom:1px solid var(--border); }
|
|
78
|
+
td.details { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:12px; word-break:break-word; }
|
|
79
|
+
.popup div { font-family:ui-monospace,Consolas,monospace; font-size:12px; }
|
|
80
|
+
footer { color:var(--muted); font-size:12px; padding:16px; text-align:center; }
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<header>
|
|
85
|
+
<h1>@@TITLE@@</h1>
|
|
86
|
+
<div class="meta">@@META@@</div>
|
|
87
|
+
<div class="cards">@@CARDS@@</div>
|
|
88
|
+
</header>
|
|
89
|
+
<main>
|
|
90
|
+
@@MAP@@
|
|
91
|
+
<div class="table-wrap"><table>
|
|
92
|
+
<thead><tr><th>Change</th><th>Feature</th><th>Geometry</th><th>Details</th></tr></thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
@@ROWS@@
|
|
95
|
+
</tbody>
|
|
96
|
+
</table></div>
|
|
97
|
+
</main>
|
|
98
|
+
<footer>Generated by geojson-diff @@VERSION@@</footer>
|
|
99
|
+
<script id="gjd-data" type="application/json">@@DATA@@</script>
|
|
100
|
+
<script src="@@LEAFLET_JS@@" integrity="@@LEAFLET_JS_SRI@@" crossorigin=""></script>
|
|
101
|
+
<script>
|
|
102
|
+
(function () {
|
|
103
|
+
"use strict";
|
|
104
|
+
var data = JSON.parse(document.getElementById("gjd-data").textContent);
|
|
105
|
+
var el = document.getElementById("map");
|
|
106
|
+
if (!el || typeof L === "undefined") { return; }
|
|
107
|
+
var map = L.map(el);
|
|
108
|
+
L.tileLayer(data.tiles, { maxZoom: 19, attribution: data.attribution }).addTo(map);
|
|
109
|
+
function style(f) {
|
|
110
|
+
var p = f.properties, before = p["diff:role"] === "before";
|
|
111
|
+
return { color: p.stroke, weight: before ? 2 : 3, opacity: p["stroke-opacity"],
|
|
112
|
+
fillColor: p.fill, fillOpacity: p["fill-opacity"], dashArray: before ? "5 5" : null };
|
|
113
|
+
}
|
|
114
|
+
function popup(f) {
|
|
115
|
+
var box = document.createElement("div");
|
|
116
|
+
box.className = "popup";
|
|
117
|
+
var head = document.createElement("strong");
|
|
118
|
+
head.textContent = f.properties.title;
|
|
119
|
+
box.appendChild(head);
|
|
120
|
+
(f.properties["diff:details"] || []).forEach(function (line) {
|
|
121
|
+
var row = document.createElement("div");
|
|
122
|
+
row.textContent = line;
|
|
123
|
+
box.appendChild(row);
|
|
124
|
+
});
|
|
125
|
+
return box;
|
|
126
|
+
}
|
|
127
|
+
function layer(role) {
|
|
128
|
+
return L.geoJSON(data.layer, {
|
|
129
|
+
filter: function (f) { return f.properties["diff:role"] === role; },
|
|
130
|
+
style: style,
|
|
131
|
+
pointToLayer: function (f, latlng) {
|
|
132
|
+
var s = style(f);
|
|
133
|
+
s.radius = role === "before" ? 5 : 7;
|
|
134
|
+
s.fillOpacity = role === "before" ? 0.2 : 0.8;
|
|
135
|
+
return L.circleMarker(latlng, s);
|
|
136
|
+
},
|
|
137
|
+
onEachFeature: function (f, lyr) { lyr.bindPopup(popup(f)); }
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
var before = layer("before").addTo(map);
|
|
141
|
+
var after = layer("change").addTo(map);
|
|
142
|
+
L.control.layers(null, { "Before (old geometry of moved features)": before,
|
|
143
|
+
"After / added / removed": after }, { collapsed: false }).addTo(map);
|
|
144
|
+
var b = data.bbox;
|
|
145
|
+
if (b) {
|
|
146
|
+
if (b[0] === b[2] && b[1] === b[3]) { map.setView([b[1], b[0]], 17); }
|
|
147
|
+
else { map.fitBounds([[b[1], b[0]], [b[3], b[2]]], { padding: [24, 24], maxZoom: 18 }); }
|
|
148
|
+
} else { map.setView([0, 0], 2); }
|
|
149
|
+
})();
|
|
150
|
+
</script>
|
|
151
|
+
</body>
|
|
152
|
+
</html>
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render_html(
|
|
157
|
+
result: DiffResult,
|
|
158
|
+
title: str = "",
|
|
159
|
+
tiles: str = DEFAULT_TILES,
|
|
160
|
+
attribution: str = OSM_ATTRIBUTION,
|
|
161
|
+
max_rows: int = 2000,
|
|
162
|
+
) -> str:
|
|
163
|
+
"""A single HTML file: summary cards, a Leaflet map and a table of changes.
|
|
164
|
+
|
|
165
|
+
Leaflet is loaded from unpkg with Subresource Integrity; OpenStreetMap
|
|
166
|
+
tiles are attributed. Every data value is HTML-escaped in the table and
|
|
167
|
+
embedded as escaped JSON for the map, whose popups use ``textContent``.
|
|
168
|
+
"""
|
|
169
|
+
from .. import __version__
|
|
170
|
+
|
|
171
|
+
heading = title or f"GeoJSON diff: {result.old_name} → {result.new_name}"
|
|
172
|
+
units = result.units
|
|
173
|
+
meta = (
|
|
174
|
+
f"{result.old_count} → {result.new_count} features · matched by "
|
|
175
|
+
f"{', '.join(result.options.strategies())} · tolerance {result.options.tolerance}"
|
|
176
|
+
)
|
|
177
|
+
if result.has_changes:
|
|
178
|
+
meta += (
|
|
179
|
+
f" · area Δ {format_area(result.area_delta, units, True, True)}"
|
|
180
|
+
f" · length Δ {format_length(result.length_delta, units, True)}"
|
|
181
|
+
)
|
|
182
|
+
cards = "".join(
|
|
183
|
+
f'<div class="card"><span class="swatch" style="background:{STYLE[s]}"></span>'
|
|
184
|
+
f"{_e(s)}<b>{result.counts.get(s, 0)}</b></div>"
|
|
185
|
+
for s in STATUSES
|
|
186
|
+
)
|
|
187
|
+
rows: list[str] = []
|
|
188
|
+
changed = result.changed
|
|
189
|
+
for change in changed[:max_rows]:
|
|
190
|
+
details = "<br>".join(_e(d) for d in describe_change(change, sup=True, arrow="→"))
|
|
191
|
+
rows.append(
|
|
192
|
+
"<tr>"
|
|
193
|
+
f'<td><span class="swatch" style="background:{STYLE[change.status]}"></span>'
|
|
194
|
+
f"{_e(change.status)}</td>"
|
|
195
|
+
f"<td>{_e(change.key)}</td>"
|
|
196
|
+
f"<td>{_e(change.geometry_type or '-')}</td>"
|
|
197
|
+
f'<td class="details">{details or "-"}</td>'
|
|
198
|
+
"</tr>"
|
|
199
|
+
)
|
|
200
|
+
if len(changed) > max_rows:
|
|
201
|
+
rows.append(f'<tr><td colspan="4">… {len(changed) - max_rows} more changes</td></tr>')
|
|
202
|
+
if not changed:
|
|
203
|
+
rows.append('<tr><td colspan="4">No feature changes.</td></tr>')
|
|
204
|
+
|
|
205
|
+
layer = diff_layer(result, include_properties=False)
|
|
206
|
+
for feature in layer["features"]:
|
|
207
|
+
props: dict[str, Any] = feature["properties"]
|
|
208
|
+
props["diff:details"] = [d for d in str(props.pop("description", "")).split("; ") if d]
|
|
209
|
+
if units == "m":
|
|
210
|
+
map_html = '<div id="map" role="region" aria-label="Map of changed features"></div>'
|
|
211
|
+
else:
|
|
212
|
+
map_html = (
|
|
213
|
+
'<div class="nomap">Map not shown: the coordinates are not longitude/latitude '
|
|
214
|
+
"(RFC 7946).</div>"
|
|
215
|
+
)
|
|
216
|
+
box = result.bbox
|
|
217
|
+
data = {
|
|
218
|
+
"layer": layer,
|
|
219
|
+
"bbox": list(box) if box else None,
|
|
220
|
+
"tiles": tiles,
|
|
221
|
+
"attribution": attribution,
|
|
222
|
+
}
|
|
223
|
+
replacements = {
|
|
224
|
+
"@@TITLE@@": _e(heading),
|
|
225
|
+
"@@META@@": _e(meta),
|
|
226
|
+
"@@CARDS@@": cards,
|
|
227
|
+
"@@MAP@@": map_html,
|
|
228
|
+
"@@ROWS@@": "\n".join(rows),
|
|
229
|
+
"@@VERSION@@": _e(__version__),
|
|
230
|
+
"@@LEAFLET_CSS@@": _LEAFLET_CSS,
|
|
231
|
+
"@@LEAFLET_CSS_SRI@@": _LEAFLET_CSS_SRI,
|
|
232
|
+
"@@LEAFLET_JS@@": _LEAFLET_JS,
|
|
233
|
+
"@@LEAFLET_JS_SRI@@": _LEAFLET_JS_SRI,
|
|
234
|
+
}
|
|
235
|
+
replacements["@@DATA@@"] = script_json(data)
|
|
236
|
+
# One pass, so marker-like text inside data values is never substituted.
|
|
237
|
+
return _MARKER.sub(lambda m: replacements[m.group(0)], _TEMPLATE)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""GeoJSON "diff layer": features styled by change type (simplestyle-spec)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..core import DiffResult, FeatureChange
|
|
10
|
+
from . import describe_change
|
|
11
|
+
|
|
12
|
+
__all__ = ["STYLE", "diff_layer", "render_layer"]
|
|
13
|
+
|
|
14
|
+
STYLE: dict[str, str] = {
|
|
15
|
+
"added": "#2e7d32",
|
|
16
|
+
"removed": "#c62828",
|
|
17
|
+
"modified": "#ef6c00",
|
|
18
|
+
"moved": "#1565c0",
|
|
19
|
+
"unchanged": "#9e9e9e",
|
|
20
|
+
"before": "#757575",
|
|
21
|
+
}
|
|
22
|
+
"""Colours per change type (and for the ``before`` outline of moved features)."""
|
|
23
|
+
|
|
24
|
+
_SIMPLESTYLE = {
|
|
25
|
+
"title",
|
|
26
|
+
"description",
|
|
27
|
+
"marker-size",
|
|
28
|
+
"marker-symbol",
|
|
29
|
+
"marker-color",
|
|
30
|
+
"stroke",
|
|
31
|
+
"stroke-opacity",
|
|
32
|
+
"stroke-width",
|
|
33
|
+
"fill",
|
|
34
|
+
"fill-opacity",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _styled(
|
|
39
|
+
change: FeatureChange,
|
|
40
|
+
source: dict[str, Any] | None,
|
|
41
|
+
role: str,
|
|
42
|
+
include_properties: bool,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
color = STYLE["before"] if role == "before" else STYLE[change.status]
|
|
45
|
+
props: dict[str, Any] = {}
|
|
46
|
+
if include_properties and source is not None:
|
|
47
|
+
for key, value in (source.get("properties") or {}).items():
|
|
48
|
+
if key not in _SIMPLESTYLE and not str(key).startswith("diff:"):
|
|
49
|
+
props[key] = value
|
|
50
|
+
details = describe_change(change)
|
|
51
|
+
props.update(
|
|
52
|
+
{
|
|
53
|
+
"diff:status": change.status,
|
|
54
|
+
"diff:role": role,
|
|
55
|
+
"diff:key": change.key,
|
|
56
|
+
"diff:changed": [p.name for p in change.properties],
|
|
57
|
+
"title": f"{change.status}: {change.key}" + (" (before)" if role == "before" else ""),
|
|
58
|
+
"description": "; ".join(details),
|
|
59
|
+
"stroke": color,
|
|
60
|
+
"stroke-width": 1 if role == "before" else 2,
|
|
61
|
+
"stroke-opacity": 0.6 if role == "before" else 1.0,
|
|
62
|
+
"fill": color,
|
|
63
|
+
"fill-opacity": 0.05 if role == "before" else 0.35,
|
|
64
|
+
"marker-color": color,
|
|
65
|
+
"marker-size": "small" if role == "before" else "medium",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
if change.geometry is not None and math.isfinite(change.geometry.hausdorff or math.nan):
|
|
69
|
+
props["diff:hausdorff"] = round(change.geometry.hausdorff or 0.0, 3)
|
|
70
|
+
feature: dict[str, Any] = {
|
|
71
|
+
"type": "Feature",
|
|
72
|
+
"properties": props,
|
|
73
|
+
"geometry": (source or {}).get("geometry"),
|
|
74
|
+
}
|
|
75
|
+
if source is not None and source.get("id") is not None:
|
|
76
|
+
feature["id"] = source["id"] if role == "change" else f"{source['id']}:before"
|
|
77
|
+
return feature
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def diff_layer(
|
|
81
|
+
result: DiffResult, include_properties: bool = True, include_unchanged: bool = False
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""Build a FeatureCollection that visualises the diff.
|
|
84
|
+
|
|
85
|
+
Added, modified and moved features carry their new geometry, removed
|
|
86
|
+
features their old one; moved features also get a grey ``before``
|
|
87
|
+
feature. Styling uses the simplestyle-spec properties that GitHub's map
|
|
88
|
+
renderer understands; ``diff:*`` properties describe the change.
|
|
89
|
+
"""
|
|
90
|
+
features: list[dict[str, Any]] = []
|
|
91
|
+
for change in result.changes:
|
|
92
|
+
if change.status == "unchanged" and not include_unchanged:
|
|
93
|
+
continue
|
|
94
|
+
if change.status == "removed":
|
|
95
|
+
features.append(_styled(change, change.old, "change", include_properties))
|
|
96
|
+
continue
|
|
97
|
+
if change.status == "moved":
|
|
98
|
+
features.append(_styled(change, change.old, "before", include_properties))
|
|
99
|
+
features.append(_styled(change, change.new, "change", include_properties))
|
|
100
|
+
return {"type": "FeatureCollection", "features": features}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_layer(
|
|
104
|
+
result: DiffResult,
|
|
105
|
+
include_properties: bool = True,
|
|
106
|
+
include_unchanged: bool = False,
|
|
107
|
+
indent: int | None = None,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Serialise :func:`diff_layer` as GeoJSON text."""
|
|
110
|
+
layer = diff_layer(result, include_properties, include_unchanged)
|
|
111
|
+
return json.dumps(layer, indent=indent, ensure_ascii=False) + "\n"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Machine-readable, JSON-Patch-like output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from ..core import DiffResult
|
|
8
|
+
|
|
9
|
+
__all__ = ["render_json"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_json(result: DiffResult, include_features: bool = True, indent: int = 2) -> str:
|
|
13
|
+
"""Serialise ``result`` as JSON.
|
|
14
|
+
|
|
15
|
+
Property changes use JSON Patch vocabulary (``add`` / ``remove`` /
|
|
16
|
+
``replace`` with RFC 6901 paths) plus ``old``, ``old_type`` and
|
|
17
|
+
``new_type`` members; geometry changes add a ``replace`` of
|
|
18
|
+
``/geometry`` and a ``geometry`` block of metrics.
|
|
19
|
+
"""
|
|
20
|
+
return json.dumps(result.to_dict(include_features), indent=indent, ensure_ascii=False) + "\n"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Markdown summary for pull-request comments and CI job summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from ..core import DiffResult
|
|
8
|
+
from . import describe_change, format_area, format_length
|
|
9
|
+
|
|
10
|
+
__all__ = ["escape_markdown", "render_markdown"]
|
|
11
|
+
|
|
12
|
+
_ESCAPES = {
|
|
13
|
+
"&": "&",
|
|
14
|
+
"<": "<",
|
|
15
|
+
">": ">",
|
|
16
|
+
"|": "|",
|
|
17
|
+
"@": "@", # never ping users from data values
|
|
18
|
+
"#": "#", # never auto-link issues from data values
|
|
19
|
+
"\\": "\",
|
|
20
|
+
"`": "`",
|
|
21
|
+
"*": "*",
|
|
22
|
+
"_": "_",
|
|
23
|
+
"[": "[",
|
|
24
|
+
"]": "]",
|
|
25
|
+
"~": "~",
|
|
26
|
+
"!": "!",
|
|
27
|
+
"$": "$",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def escape_markdown(text: str) -> str:
|
|
32
|
+
"""Escape ``text`` for a GitHub-flavoured Markdown table cell.
|
|
33
|
+
|
|
34
|
+
Every character with Markdown or HTML meaning becomes an HTML entity and
|
|
35
|
+
line breaks become spaces, so data values cannot inject markup, links,
|
|
36
|
+
mentions or table columns.
|
|
37
|
+
"""
|
|
38
|
+
out = "".join(_ESCAPES.get(ch, ch) for ch in text)
|
|
39
|
+
return " ".join(out.splitlines())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_LABELS = {
|
|
43
|
+
"added": "added",
|
|
44
|
+
"removed": "removed",
|
|
45
|
+
"modified": "modified",
|
|
46
|
+
"moved": "moved",
|
|
47
|
+
"unchanged": "unchanged",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_markdown(
|
|
52
|
+
result: DiffResult,
|
|
53
|
+
max_rows: int = 100,
|
|
54
|
+
title: str = "",
|
|
55
|
+
include_map: bool = False,
|
|
56
|
+
map_limit: int = 60_000,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Markdown summary: counts table plus a collapsible table of changes.
|
|
59
|
+
|
|
60
|
+
``include_map`` appends the diff layer as a ```` ```geojson ```` block,
|
|
61
|
+
which GitHub renders as an interactive map, when it fits in ``map_limit``
|
|
62
|
+
characters.
|
|
63
|
+
"""
|
|
64
|
+
c = result.counts
|
|
65
|
+
heading = title or f"GeoJSON diff: {result.old_name} → {result.new_name}"
|
|
66
|
+
lines: list[str] = [f"### {escape_markdown(heading)}", ""]
|
|
67
|
+
total = sum(c.get(s, 0) for s in ("added", "removed", "modified", "moved"))
|
|
68
|
+
strategies = ", ".join(result.options.strategies())
|
|
69
|
+
if total:
|
|
70
|
+
lines.append(
|
|
71
|
+
f"**{total} feature change{'s' if total != 1 else ''}** "
|
|
72
|
+
f"({result.old_count} → {result.new_count} features; matched by "
|
|
73
|
+
f"{escape_markdown(strategies)}; tolerance {result.options.tolerance})"
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
lines.append(
|
|
77
|
+
f"**No feature changes** ({result.old_count} → {result.new_count} features; "
|
|
78
|
+
f"matched by {escape_markdown(strategies)}; tolerance {result.options.tolerance})"
|
|
79
|
+
)
|
|
80
|
+
lines += [
|
|
81
|
+
"",
|
|
82
|
+
"| Added | Removed | Modified | Moved | Unchanged |",
|
|
83
|
+
"|---:|---:|---:|---:|---:|",
|
|
84
|
+
"| "
|
|
85
|
+
+ " | ".join(
|
|
86
|
+
str(c.get(s, 0)) for s in ("added", "removed", "modified", "moved", "unchanged")
|
|
87
|
+
)
|
|
88
|
+
+ " |",
|
|
89
|
+
"",
|
|
90
|
+
]
|
|
91
|
+
for warning in result.warnings:
|
|
92
|
+
lines.append(f"> **Warning:** {escape_markdown(warning)}")
|
|
93
|
+
lines.append("")
|
|
94
|
+
if total:
|
|
95
|
+
units = result.units
|
|
96
|
+
box = result.bbox
|
|
97
|
+
facts = []
|
|
98
|
+
if box:
|
|
99
|
+
facts.append("bbox of changes " + ", ".join(f"{v:.6f}" for v in box))
|
|
100
|
+
facts.append(f"area Δ {format_area(result.area_delta, units, True, True)}")
|
|
101
|
+
facts.append(f"length Δ {format_length(result.length_delta, units, True)}")
|
|
102
|
+
lines.append(" · ".join(facts))
|
|
103
|
+
lines.append("")
|
|
104
|
+
changed = result.changed
|
|
105
|
+
opened = " open" if len(changed) <= 20 else ""
|
|
106
|
+
lines.append(f"<details{opened}><summary>Feature changes ({len(changed)})</summary>")
|
|
107
|
+
lines.append("")
|
|
108
|
+
lines.append("| Change | Feature | Geometry | Details |")
|
|
109
|
+
lines.append("|---|---|---|---|")
|
|
110
|
+
for change in changed[:max_rows]:
|
|
111
|
+
details = "<br>".join(
|
|
112
|
+
escape_markdown(d) for d in describe_change(change, sup=True, arrow="→")
|
|
113
|
+
)
|
|
114
|
+
lines.append(
|
|
115
|
+
f"| {_LABELS[change.status]} | {escape_markdown(change.key)} | "
|
|
116
|
+
f"{escape_markdown(change.geometry_type or '-')} | {details or '-'} |"
|
|
117
|
+
)
|
|
118
|
+
if len(changed) > max_rows:
|
|
119
|
+
lines.append(f"| … | {len(changed) - max_rows} more | | |")
|
|
120
|
+
lines.append("")
|
|
121
|
+
lines.append("</details>")
|
|
122
|
+
lines.append("")
|
|
123
|
+
if include_map:
|
|
124
|
+
from .layer import diff_layer
|
|
125
|
+
|
|
126
|
+
layer = json.dumps(diff_layer(result, include_properties=False), ensure_ascii=False)
|
|
127
|
+
# Unicode escapes keep data from closing the fence or carrying markup.
|
|
128
|
+
for char, code in (
|
|
129
|
+
("`", "\\u0060"),
|
|
130
|
+
("<", "\\u003c"),
|
|
131
|
+
(">", "\\u003e"),
|
|
132
|
+
("&", "\\u0026"),
|
|
133
|
+
):
|
|
134
|
+
layer = layer.replace(char, code)
|
|
135
|
+
if len(layer) <= map_limit:
|
|
136
|
+
lines += ["```geojson", layer, "```", ""]
|
|
137
|
+
else:
|
|
138
|
+
lines += [
|
|
139
|
+
f"_Map omitted: the diff layer exceeds {map_limit} characters._",
|
|
140
|
+
"",
|
|
141
|
+
]
|
|
142
|
+
lines.append("<sub>Generated by geojson-diff</sub>")
|
|
143
|
+
return "\n".join(lines) + "\n"
|