diff --git a/examples/pyshp/README.md b/examples/pyshp/README.md new file mode 100644 index 0000000..6be703f --- /dev/null +++ b/examples/pyshp/README.md @@ -0,0 +1,18 @@ +# pyshp Examples + +Each sub-directory contains a self-contained example. The order in +which the examples are to appear is specified in `order.json` (an +array of directory names in the expected order). + +In each example directory you'll find: + +* `config.toml` - must conform to the specification outlined here: + https://docs.pyscript.net/latest/user-guide/configuration/ This is + parsed and ultimately turned into a JSON representation as part of + the package's API object. +* `setup.py` - Python code for contextual and environmental setup, + NOT SEEN BY THE END USER, but is run before the `code.py` code is + evaluated. Allows us to create useful (IPython) shims, avoid + repeating boilerplate and whatnot. +* `code.py` - the actual code added to the editor which forms the + practical example of using the package. diff --git a/examples/pyshp/filter_and_edit/code.py b/examples/pyshp/filter_and_edit/code.py new file mode 100644 index 0000000..f709c4f --- /dev/null +++ b/examples/pyshp/filter_and_edit/code.py @@ -0,0 +1,123 @@ +# --------------------------------------------------------------------- +# A realistic editing workflow: build a source shapefile, then stream +# through it filtering by attribute and bounding box, writing only the +# matching features (and only the fields we care about) to a new file. +# --------------------------------------------------------------------- +import shapefile +import io +import matplotlib.pyplot as plt + + +heading("Source data: river segments across two basins") +note( + "We'll synthesize a polyline shapefile of fictional river " + "segments tagged by basin and length, then produce a derived " + "shapefile containing only the long segments in one basin." +) + +# Build the source shapefile in memory. +src_shp, src_shx, src_dbf = io.BytesIO(), io.BytesIO(), io.BytesIO() + +rivers = [ + ("Otter Brook", "North", 4.2, [(0, 0), (1, 2), (2, 5)]), + ("Heron Creek", "North", 1.1, [(2, 5), (3, 5)]), + ("Pine River", "North", 8.7, [(3, 5), (5, 6), (8, 8), (10, 9)]), + ("Willow Run", "South", 2.5, [(0, -1), (2, -2), (4, -2)]), + ("Birch Stream", "South", 6.0, [(4, -2), (7, -3), (10, -4)]), + ("Cedar Wash", "South", 0.9, [(7, -3), (8, -3)]), +] + +with shapefile.Writer(shp=src_shp, shx=src_shx, dbf=src_dbf) as writer: + writer.field("name", "C", size=40) + writer.field("basin", "C", size=10) + writer.field("length_km", "N", decimal=1) + writer.field("notes", "C", size=80) # a field we'll later drop + + for name, basin, length, coords in rivers: + writer.line([coords]) + writer.record(name, basin, length, "auto-generated sample") + +heading("Streaming filter and rewrite", level=3) +note( + "We open the source with a Reader, request only the fields we " + "need via iterShapeRecords(fields=...), and use a " + "bbox argument so the Reader's spatial index " + "skips features whose bounding boxes can't possibly match." +) + +for buf in (src_shp, src_shx, src_dbf): + buf.seek(0) + +dst_shp, dst_shx, dst_dbf = io.BytesIO(), io.BytesIO(), io.BytesIO() + +# Region of interest: a bounding box covering the eastern half. +region_bbox = [3, -5, 11, 10] +keep_fields = ["name", "basin", "length_km"] + +reader = shapefile.Reader(shp=src_shp, shx=src_shx, dbf=src_dbf) +writer = shapefile.Writer(shp=dst_shp, shx=dst_shx, dbf=dst_dbf) + +# Copy only the fields we want to keep. PyShp returns each field as a +# plain list, [name, type, size, decimal], not an object with named +# attributes -- so we index by position. The first entry is always a +# DeletionFlag, which we skip. +for field in reader.fields[1:]: + if field[0] in keep_fields: + writer.field(*field) + +kept = [] +for shape_record in reader.iterShapeRecords( + bbox=region_bbox, fields=keep_fields, +): + record = shape_record.record + if record["basin"] == "North" and record["length_km"] >= 4.0: + writer.shape(shape_record.shape) + writer.record(*[record[name] for name in keep_fields]) + kept.append(record["name"]) + +writer.close() +reader.close() + +note(f"Kept {len(kept)} feature(s): " + ", ".join(kept)) + +# Read the new shapefile back and visualize the result. +for buf in (dst_shp, dst_shx, dst_dbf): + buf.seek(0) + +result = shapefile.Reader(shp=dst_shp, shx=dst_shx, dbf=dst_dbf) + +note( + f"Output shapefile: {result.shapeTypeName}, " + f"{len(result)} feature(s), " + f"{len(result.fields) - 1} field(s) " + "(notice the notes field was dropped)." +) + +fig, ax = plt.subplots(figsize=(7, 4)) + +# Draw the original rivers in light gray for context. +src_shp.seek(0); src_shx.seek(0); src_dbf.seek(0) +context = shapefile.Reader(shp=src_shp, shx=src_shx, dbf=src_dbf) +for shape in context.iterShapes(): + xs, ys = zip(*shape.points) + ax.plot(xs, ys, color="lightgray", linewidth=1) +context.close() + +# Overlay the surviving features in bold. +for shape_record in result.iterShapeRecords(): + xs, ys = zip(*shape_record.shape.points) + ax.plot(xs, ys, linewidth=2.5, + label=shape_record.record["name"]) + +# Show the query bounding box. +x0, y0, x1, y1 = region_bbox +ax.plot([x0, x1, x1, x0, x0], [y0, y0, y1, y1, y0], + linestyle="--", color="darkorange", label="query bbox") + +ax.set_aspect("equal") +ax.set_title("Long rivers in the North basin, within the query box") +ax.legend(loc="lower right", fontsize=8) +fig.tight_layout() +display(fig, append=True) + +result.close() \ No newline at end of file diff --git a/examples/pyshp/filter_and_edit/config.toml b/examples/pyshp/filter_and_edit/config.toml new file mode 100644 index 0000000..4b2116f --- /dev/null +++ b/examples/pyshp/filter_and_edit/config.toml @@ -0,0 +1 @@ +packages = ["pyshp", "matplotlib"] diff --git a/examples/pyshp/filter_and_edit/setup.py b/examples/pyshp/filter_and_edit/setup.py new file mode 100644 index 0000000..60d91f9 --- /dev/null +++ b/examples/pyshp/filter_and_edit/setup.py @@ -0,0 +1,18 @@ +"""Lightweight setup for the third example.""" +import js +from pyscript import window, HTML, display as _display + +js.alert = window.alert + + +def display(*args, **kwargs): + return _display(*args, **kwargs, target=__pyscript_display_target__) + + +def heading(text, level=2): + display(HTML(f"{text}"), append=True) + + +def note(text): + display(HTML(f"

{text}

"), append=True) + diff --git a/examples/pyshp/order.json b/examples/pyshp/order.json new file mode 100644 index 0000000..3fc1195 --- /dev/null +++ b/examples/pyshp/order.json @@ -0,0 +1,5 @@ +[ + "write_and_read_points", + "polygons_and_geojson", + "filter_and_edit" +] diff --git a/examples/pyshp/polygons_and_geojson/code.py b/examples/pyshp/polygons_and_geojson/code.py new file mode 100644 index 0000000..b4eaaf9 --- /dev/null +++ b/examples/pyshp/polygons_and_geojson/code.py @@ -0,0 +1,120 @@ +# --------------------------------------------------------------------- +# Polygon shapefiles, holes, and GeoJSON via __geo_interface__. +# --------------------------------------------------------------------- + +import shapefile +import io +import matplotlib.pyplot as plt +from matplotlib.path import Path +from matplotlib.patches import PathPatch + +heading("Tiny parks dataset") +note( + "Polygons in shapefiles must be closed (the last point repeats " + "the first). Crucially, the shapefile format has no flag to " + "distinguish an outer ring from a hole — the only signal is " + "winding order: outer rings clockwise, holes counterclockwise. " + "Get this wrong and tools that consume the file (including " + "PyShp's own GeoJSON conversion) will misread your geometry." +) + +shp_buf, shx_buf, dbf_buf = io.BytesIO(), io.BytesIO(), io.BytesIO() + +# A square park with a square pond cut out, plus a triangular plaza. +# Outer rings are clockwise; holes are counterclockwise. +park_outer = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)] # clockwise +pond_hole = [(3, 3), (3, 7), (7, 7), (7, 3), (3, 3)] # counterclockwise +plaza = [(15, 2), (17, 8), (20, 2), (15, 2)] # clockwise + +with shapefile.Writer(shp=shp_buf, shx=shx_buf, dbf=dbf_buf) as writer: + writer.field("name", "C", size=30) + writer.field("area_sqm", "N", decimal=1) + + # A single polygon feature with an outer ring and a hole. + writer.poly([park_outer, pond_hole]) + writer.record("Riverside Park", 84.0) + + # A separate polygon feature with no holes. + writer.poly([plaza]) + writer.record("Civic Plaza", 15.0) + +for buf in (shp_buf, shx_buf, dbf_buf): + buf.seek(0) + +reader = shapefile.Reader(shp=shp_buf, shx=shx_buf, dbf=dbf_buf) + +heading("Inspecting parts and points", level=3) +for shape_record in reader.iterShapeRecords(): + shape = shape_record.shape + name = shape_record.record["name"] + note( + f"{name}: shape type " + f"{shape.shapeTypeName}, " + f"{len(shape.points)} points across " + f"{len(shape.parts)} ring(s) " + f"(part start indices: {list(shape.parts)})." + ) + +heading("GeoJSON for free", level=3) +note( + "Every Shape, Record, and Reader implements " + "__geo_interface__, so converting to GeoJSON is " + "a one-liner that any geospatial tool can consume." +) +geojson = reader.__geo_interface__ +display(HTML( + f"
type: {geojson['type']}\\n"
+    f"features: {len(geojson['features'])}\\n"
+    f"first geometry type: {geojson['features'][0]['geometry']['type']}"
+    "
" +), append=True) + +heading("Plotting polygons with real holes", level=3) +note( + "GeoJSON's nested-ring structure encodes holes semantically, but " + "matplotlib needs you to translate that into a compound " + "Path: outer ring plus inner rings as sub-paths in a " + "single PathPatch. This renders holes as genuinely " + "transparent — unlike the common shortcut of overpainting with a " + "white fill, which breaks the moment you have a non-white " + "background or layered features." +) + +# Plot the polygons using their GeoJSON coordinates. +fig, ax = plt.subplots(figsize=(7, 4)) +colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] + +for i, feature in enumerate(geojson["features"]): + geom = feature["geometry"] + name = feature["properties"]["name"] + color = colors[i % len(colors)] + # GeoJSON nests rings as [[outer, hole, ...]] for Polygons, + # and one level deeper for MultiPolygons. + polygons = ( + geom["coordinates"] + if geom["type"] == "MultiPolygon" + else [geom["coordinates"]] + ) + for rings in polygons: + # Build a compound path: outer ring + holes as sub-paths. + # Matplotlib renders the holes as genuinely transparent when + # the path alternates winding between outer and inner rings. + vertices = [] + codes = [] + for ring in rings: + vertices.extend(ring) + codes.append(Path.MOVETO) + codes.extend([Path.LINETO] * (len(ring) - 2)) + codes.append(Path.CLOSEPOLY) + path = Path(vertices, codes) + patch = PathPatch(path, facecolor=color, alpha=0.4, label=name) + ax.add_patch(patch) + +ax.set_aspect("equal") +ax.autoscale_view() +ax.set_title("Parks polygons (with a pond-shaped hole)") +ax.legend(loc="upper right") +fig.tight_layout() +display(fig, append=True) + +reader.close() \ No newline at end of file diff --git a/examples/pyshp/polygons_and_geojson/config.toml b/examples/pyshp/polygons_and_geojson/config.toml new file mode 100644 index 0000000..4b2116f --- /dev/null +++ b/examples/pyshp/polygons_and_geojson/config.toml @@ -0,0 +1 @@ +packages = ["pyshp", "matplotlib"] diff --git a/examples/pyshp/polygons_and_geojson/setup.py b/examples/pyshp/polygons_and_geojson/setup.py new file mode 100644 index 0000000..1e05eaa --- /dev/null +++ b/examples/pyshp/polygons_and_geojson/setup.py @@ -0,0 +1,17 @@ +"""Lightweight setup for the second example.""" +import js +from pyscript import window, HTML, display as _display + +js.alert = window.alert + + +def display(*args, **kwargs): + return _display(*args, **kwargs, target=__pyscript_display_target__) + + +def heading(text, level=2): + display(HTML(f"{text}"), append=True) + + +def note(text): + display(HTML(f"

{text}

"), append=True) diff --git a/examples/pyshp/write_and_read_points/code.py b/examples/pyshp/write_and_read_points/code.py new file mode 100644 index 0000000..09b5130 --- /dev/null +++ b/examples/pyshp/write_and_read_points/code.py @@ -0,0 +1,82 @@ +""" +A first look at PyShp: write a small Point shapefile entirely in +memory, then read it back to verify the geometry and attributes. + +PyShp is the pure-Python reader/writer for the ESRI Shapefile format. +The PyPI distribution is named `pyshp`, but the import name is +`shapefile`. Docs and source: https://github.com/GeospatialPython/pyshp +""" +from IPython.core.display import display, HTML +# pyshp is imported as the `shapefile` module. +import shapefile +import io + + +heading("A handful of lighthouses") +note( + "A shapefile is really three files working together: " + ".shp (geometry), .shx (index), " + "and .dbf (attribute table). PyShp can read and " + "write each one to a file-like object, which is exactly what " + "we want in the browser." +) + +# Three buffers stand in for the three files on disk. +shp_buffer = io.BytesIO() +shx_buffer = io.BytesIO() +dbf_buffer = io.BytesIO() + +# A small dataset: famous lighthouses with their (lon, lat) coordinates. +lighthouses = [ + ("Eddystone", -4.2636, 50.1922, 1882), + ("Fastnet", -9.6033, 51.3833, 1904), + ("Cape Hatteras", -75.5290, 35.2503, 1870), + ("Tower of Hercules", -8.4063, 43.3863, 100), +] + +# Build the shapefile by streaming records into the Writer. +with shapefile.Writer( + shp=shp_buffer, shx=shx_buffer, dbf=dbf_buffer, +) as writer: + # Define the attribute schema before adding any records. + writer.field("name", "C", size=40) # text + writer.field("year", "N", decimal=0) # integer year built + + for name, lon, lat, year in lighthouses: + writer.point(lon, lat) + writer.record(name, year) + +note(f"Wrote {len(lighthouses)} point features to in-memory buffers.") + +# Reading is symmetric: hand the buffers to a Reader. +for buf in (shp_buffer, shx_buffer, dbf_buffer): + buf.seek(0) + +reader = shapefile.Reader( + shp=shp_buffer, shx=shx_buffer, dbf=dbf_buffer, +) + +heading("What did we just write?", level=3) +note( + f"Shape type: {reader.shapeTypeName}. " + f"Feature count: {len(reader)}. " + f"Bounding box: {tuple(round(c, 3) for c in reader.bbox)}." +) + +heading("Iterating shape/record pairs", level=3) +rows = [] +for shape_record in reader.iterShapeRecords(): + (lon, lat) = shape_record.shape.points[0] + name = shape_record.record["name"] + year = shape_record.record["year"] + rows.append(f"{name}{year}" + f"{lon:.3f}, {lat:.3f}") + +table = ( + "" + "" + + "".join(rows) + "
NameBuiltLon, Lat
" +) +display(HTML(table), append=True) + +reader.close() diff --git a/examples/pyshp/write_and_read_points/config.toml b/examples/pyshp/write_and_read_points/config.toml new file mode 100644 index 0000000..5a340a9 --- /dev/null +++ b/examples/pyshp/write_and_read_points/config.toml @@ -0,0 +1 @@ +packages = ["pyshp"] diff --git a/examples/pyshp/write_and_read_points/setup.py b/examples/pyshp/write_and_read_points/setup.py new file mode 100644 index 0000000..17b1db0 --- /dev/null +++ b/examples/pyshp/write_and_read_points/setup.py @@ -0,0 +1,37 @@ +"""Shim setup for the first example. Includes the full IPython shim.""" +import sys +import types +import js +from pyscript import window, HTML, display as _display + +js.alert = window.alert + + +def display(*args, **kwargs): + return _display( + *args, **kwargs, target=__pyscript_display_target__, + ) + + +ipython = types.ModuleType("IPython") +core = types.ModuleType("IPython.core") +core_display = types.ModuleType("IPython.core.display") +core_display.display = display +core_display.HTML = HTML +ipython.core = core +core.display = core_display +ipython.get_ipython = lambda: None +ipython.display = core_display +sys.modules["IPython"] = ipython +sys.modules["IPython.core"] = core +sys.modules["IPython.core.display"] = core_display +sys.modules["IPython.display"] = core_display + + +def heading(text, level=2): + display(HTML(f"{text}"), append=True) + + +def note(text): + display(HTML(f"

{text}

"), append=True) +