File size: 12,677 Bytes
8f53057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
#!/usr/bin/env python3
"""Builds assets/ from the real datasets in data/raw/.

  assets/mask.png       RGB, W x H.  R = land coverage (0-255), G = ferry corridor, B = 0
  assets/cities.json    [[name, lat, lon, population], ...] sorted by population, desc
  assets/overlays.json  {key: {"lines": [[[lon, lat], ...], ...], "km": total_km}}

The grid and projection are shared with app.js. Do not change one without the other.
"""
import io
import json
import math
import os
import sys
import zipfile

from PIL import Image, ImageDraw
from shapely.geometry import shape, LineString, MultiLineString, Point, box
from shapely.ops import unary_union, linemerge
from shapely.prepared import prep

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, 'data', 'raw')
OUT = os.path.join(ROOT, 'assets')
os.makedirs(OUT, exist_ok=True)

W, H = 1360, 1600
LON0, LON1, LAT0, LAT1 = 6.5, 18.7, 36.5, 47.2
K_LAT = H / (LAT1 - LAT0)
K_LON = K_LAT * math.cos(math.radians(41.9))
SS = 4  # supersampling factor for the mask
BBOX = box(LON0, LAT0, LON1, LAT1)


def proj(lon, lat):
    return (lon - LON0) * K_LON, (LAT1 - lat) * K_LAT


def km(coords):
    """Length of a lon/lat polyline in km (haversine)."""
    total = 0.0
    for (a, b) in zip(coords, coords[1:]):
        la1, lo1, la2, lo2 = map(math.radians, (a[1], a[0], b[1], b[0]))
        h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2
        total += 2 * 6371.0 * math.asin(math.sqrt(h))
    return total


def log(*a):
    print(*a, file=sys.stderr, flush=True)


# ---------------------------------------------------------------- land
def load_land():
    polys = []
    src = None
    p = os.path.join(RAW, 'gb_ITA_ADM0.geojson')
    try:
        gj = json.load(open(p))
        polys += [shape(f['geometry']) for f in gj['features']]
        src = 'geoBoundaries (OSM-derived)'
    except Exception as e:  # truncated download or missing
        log('geoBoundaries ITA unusable (%s); falling back to Natural Earth 10m' % e)
        import shapefile
        z = zipfile.ZipFile(os.path.join(RAW, 'ne_10m_admin_0_countries.zip'))
        r = shapefile.Reader(shp=io.BytesIO(z.read('ne_10m_admin_0_countries.shp')),
                             dbf=io.BytesIO(z.read('ne_10m_admin_0_countries.dbf')),
                             shx=io.BytesIO(z.read('ne_10m_admin_0_countries.shx')))
        fields = [f[0] for f in r.fields[1:]]
        for sr in r.shapeRecords():
            rec = dict(zip(fields, sr.record))
            if rec.get('ADM0_A3') in ('ITA', 'SMR', 'VAT'):
                polys.append(shape(sr.shape.__geo_interface__))
        src = 'Natural Earth 10m'
    for iso in ('SMR', 'VAT'):
        p = os.path.join(RAW, 'gb_%s_ADM0.geojson' % iso)
        try:
            gj = json.load(open(p))
            polys += [shape(f['geometry']) for f in gj['features']]
        except Exception:
            pass
    land = unary_union([g.buffer(0) for g in polys]).intersection(BBOX)
    # Administrative polygons treat lakes as territory; the mold should not swim across Garda.
    lakes_zip = os.path.join(RAW, 'ne_10m_lakes.zip')
    if os.path.exists(lakes_zip):
        import shapefile
        z = zipfile.ZipFile(lakes_zip)
        r = shapefile.Reader(shp=io.BytesIO(z.read('ne_10m_lakes.shp')),
                             dbf=io.BytesIO(z.read('ne_10m_lakes.dbf')),
                             shx=io.BytesIO(z.read('ne_10m_lakes.shx')))
        lakes = [shape(sr.shape.__geo_interface__).buffer(0) for sr in r.shapeRecords()]
        lakes = [l for l in lakes if l.intersects(BBOX) and l.area > 0.002]  # > ~20 km2
        if lakes:
            land = land.difference(unary_union(lakes))
            log('lakes subtracted:', len(lakes))
    return land, src


def rasterize_land(land):
    img = Image.new('L', (W * SS, H * SS), 0)
    d = ImageDraw.Draw(img)
    geoms = list(land.geoms) if hasattr(land, 'geoms') else [land]
    n = 0
    for g in geoms:
        if g.is_empty or g.geom_type != 'Polygon':
            continue
        ext = [tuple(v * SS for v in proj(x, y)) for x, y in g.exterior.coords]
        if len(ext) >= 3:
            d.polygon(ext, fill=255)
            n += 1
        for ring in g.interiors:
            hole = [tuple(v * SS for v in proj(x, y)) for x, y in ring.coords]
            if len(hole) >= 3:
                d.polygon(hole, fill=0)
    log('land polygons drawn:', n)
    return img.resize((W, H), Image.BOX)


# ---------------------------------------------------------------- ferries
def load_ferries(land):
    p = os.path.join(RAW, 'osm_ferry.json')
    if not os.path.exists(p):
        return []
    els = json.load(open(p)).get('elements', [])
    land_prep = prep(land)
    near = prep(land.buffer(0.03))  # ~3 km
    kept = []
    for w in els:
        geom = w.get('geometry') or []
        coords = [(g['lon'], g['lat']) for g in geom]
        if len(coords) < 2:
            continue
        length = km(coords)
        if length < 2.5:
            continue
        a, b = Point(coords[0]), Point(coords[-1])
        if not (near.contains(a) and near.contains(b)):
            continue  # international or lake-to-nowhere
        mid = LineString(coords).interpolate(0.5, normalized=True)
        if land_prep.contains(mid):
            continue  # lake or river ferry: water inside the land polygon
        # Venice lagoon vaporetti are mapped as ferries; the lagoon is water in the mask,
        # but those are boats between quays, not sea corridors.
        minx, miny, maxx, maxy = LineString(coords).bounds
        if 12.2 < minx and maxx < 12.65 and 45.15 < miny and maxy < 45.65:
            continue
        name = (w.get('tags') or {}).get('name', '')
        kept.append({'coords': coords, 'km': length, 'name': name})
    kept.sort(key=lambda k: -k['km'])
    log('ferry corridors kept: %d of %d' % (len(kept), len(els)))
    for k in kept[:40]:
        log('   %6.1f km  %s' % (k['km'], k['name']))
    return kept


def rasterize_ferries(ferries):
    img = Image.new('L', (W * SS, H * SS), 0)
    d = ImageDraw.Draw(img)
    for f in ferries:
        pts = [tuple(v * SS for v in proj(x, y)) for x, y in f['coords']]
        d.line(pts, fill=255, width=3 * SS, joint='curve')
    return img.resize((W, H), Image.BOX)


# ---------------------------------------------------------------- towns
EXONYMS = {
    'Rome': 'Roma', 'Milan': 'Milano', 'Naples': 'Napoli', 'Turin': 'Torino', 'Florence': 'Firenze',
    'Venice': 'Venezia', 'Genoa': 'Genova', 'Padua': 'Padova', 'Syracuse': 'Siracusa', 'Mantua': 'Mantova',
    'Leghorn': 'Livorno', 'Sassari': 'Sassari', 'Bolzano': 'Bolzano', 'Trent': 'Trento', 'Lucca': 'Lucca',
}


def load_cities():
    z = zipfile.ZipFile(os.path.join(RAW, 'cities5000.zip'))
    rows = []
    with z.open('cities5000.txt') as f:
        for line in io.TextIOWrapper(f, encoding='utf-8'):
            c = line.rstrip('\n').split('\t')
            if c[8] not in ('IT', 'SM', 'VA') or c[6] != 'P':
                continue
            lat, lon, pop = float(c[4]), float(c[5]), int(c[14] or 0)
            if pop <= 0 or not (LON0 <= lon <= LON1 and LAT0 <= lat <= LAT1):
                continue
            name = EXONYMS.get(c[1], c[1])
            rows.append([name, round(lat, 4), round(lon, 4), pop])
    rows.sort(key=lambda r: -r[3])
    log('towns kept: %d (>=15k: %d, >=50k: %d)' % (len(rows), sum(r[3] >= 15000 for r in rows), sum(r[3] >= 50000 for r in rows)))
    return rows


# ---------------------------------------------------------------- overlays
def pack(geoms, tol, nd):
    """Merge touching ways into long polylines, simplify, round. Keeps the JSON small."""
    merged = linemerge([g for g in geoms if g.geom_type == 'LineString' and len(g.coords) >= 2])
    parts = list(merged.geoms) if hasattr(merged, 'geoms') else [merged]
    out = []
    for part in parts:
        ls = part.simplify(tol, preserve_topology=False)
        if len(ls.coords) >= 2:
            out.append([[round(x, nd), round(y, nd)] for x, y in ls.coords])
    return out


def osm_lines(fname, land, tol=0.0008, nd=4):
    """OSM ways from an Overpass bbox query, clipped to Italian territory (the bbox also catches
    Swiss, Austrian, French and Slovenian track)."""
    p = os.path.join(RAW, fname)
    if not os.path.exists(p):
        return None
    els = json.load(open(p)).get('elements', [])
    clip = land.buffer(0.015)
    clip_prep = prep(clip)
    geoms, total, n = [], 0.0, 0
    for w in els:
        coords = [(g['lon'], g['lat']) for g in (w.get('geometry') or [])]
        if len(coords) < 2:
            continue
        ls = LineString(coords)
        if not clip_prep.intersects(ls):
            continue
        if not clip_prep.contains(ls):
            ls = ls.intersection(clip)
        parts = list(ls.geoms) if hasattr(ls, 'geoms') else [ls]
        for part in parts:
            if part.geom_type == 'LineString' and len(part.coords) >= 2:
                total += km(list(part.coords))
                geoms.append(part)
        n += 1
    lines = pack(geoms, tol, nd)
    log('%s: %d ways -> %d polylines, %.0f km' % (fname, n, len(lines), total))
    return {'lines': lines, 'km': round(total)}


def roman_lines(land, tol=0.0015):
    p = os.path.join(RAW, 'itinere_roads.geojson')
    if not os.path.exists(p):
        return None
    try:
        gj = json.load(open(p))
    except Exception as e:
        log('itinere unusable:', e)
        return None
    feats = gj['features']
    # Itiner-e ships in EPSG:3395 (World Mercator, metres); convert to lon/lat.
    R = 6378137.0

    def to_lonlat(x, y):
        return math.degrees(x / R), math.degrees(2 * math.atan(math.exp(y / R)) - math.pi / 2)

    land_prep = prep(land.buffer(0.01))
    geoms, total, kept, main = [], 0.0, 0, 0
    for f in feats:
        g = f.get('geometry')
        if not g:
            continue
        props = f.get('properties') or {}
        if props.get('Type') != 'Main Road':   # viae publicae only; secondary roads triple the size
            continue
        main += 1
        parts = g['coordinates'] if g['type'] == 'MultiLineString' else [g['coordinates']]
        for part in parts:
            coords = [to_lonlat(x, y) for x, y, *_ in part]
            if len(coords) < 2:
                continue
            ls = LineString(coords)
            if not ls.intersects(BBOX) or not land_prep.intersects(ls):
                continue
            total += km(coords)
            geoms.append(ls)
            kept += 1
    lines = pack(geoms, tol, 3)
    log('itinere: %d main-road features, %d segments touch Italy -> %d polylines, %.0f km' % (main, kept, len(lines), total))
    return {'lines': lines, 'km': round(total)}


def main():
    land, src = load_land()
    log('land source:', src, '| polygons:', len(land.geoms) if hasattr(land, 'geoms') else 1)
    landimg = rasterize_land(land)
    ferries = load_ferries(land)
    ferryimg = rasterize_ferries(ferries)
    mask = Image.merge('RGB', (landimg, ferryimg, Image.new('L', (W, H), 0)))
    mask.save(os.path.join(OUT, 'mask.png'), optimize=True)
    land_cells = sum(1 for v in landimg.getdata() if v >= 128)
    log('mask.png written: %dx%d, land cells %d (%.1f%%)' % (W, H, land_cells, 100.0 * land_cells / (W * H)))

    cities = load_cities()
    json.dump(cities, open(os.path.join(OUT, 'cities.json'), 'w'), ensure_ascii=False, separators=(',', ':'))

    overlays = {}
    for key, fname, tol, nd in (('hsr', 'osm_hsr.json', 0.0008, 4), ('rail', 'osm_rail_main.json', 0.0015, 3), ('motorway', 'osm_motorway.json', 0.0015, 3)):
        o = osm_lines(fname, land, tol, nd)
        if o:
            overlays[key] = o
    if ferries:
        overlays['ferry'] = {'lines': [[[round(x, 4), round(y, 4)] for x, y in f['coords']] for f in ferries],
                             'km': round(sum(f['km'] for f in ferries))}
    r = roman_lines(land)
    if r:
        overlays['roman'] = r
    json.dump(overlays, open(os.path.join(OUT, 'overlays.json'), 'w'), separators=(',', ':'))
    for k, v in overlays.items():
        log('overlay %-8s %5d lines %7d km' % (k, len(v['lines']), v['km']))
    summary = {'land_source': src, 'towns': len(cities), 'ferries': len(ferries), 'overlays': {k: {'lines': len(v['lines']), 'km': v['km']} for k, v in overlays.items()}}
    json.dump(summary, open(os.path.join(ROOT, 'data', 'summary.json'), 'w'), indent=1)
    for f in ('mask.png', 'cities.json', 'overlays.json'):
        log('%-14s %8.1f KB' % (f, os.path.getsize(os.path.join(OUT, f)) / 1024))


if __name__ == '__main__':
    main()