~phroa/boat-map

177d480ceecd5e0d5db752a38175d6f8ed14c989 — Jack Stratton 9 months ago
Initial commit
4 files changed, 129 insertions(+), 0 deletions(-)

A make-map.py
A moving.png
A requirements.txt
A stopped.png
A  => make-map.py +126 -0
@@ 1,126 @@
import os
import requests
import simdjson
from PIL import Image, ImageDraw

# https://geojson.io, draw a rectangle
MAP = [
    [-123.04893493652342, 48.478838892384104],
    [-122.65480041503905, 48.478838892384104],
    [-122.65480041503905, 48.62610126300646],
    [-123.04893493652342, 48.62610126300646],
]

VESSELS = "https://www.wsdot.com/ferries/vesselwatch/Vessels.ashx"
OVERPASS = "https://overpass-api.de/api/interpreter"

MIN_LON = min(MAP, key=lambda coord: coord[0])[0]
MIN_LAT = min(MAP, key=lambda coord: coord[1])[1]
MAX_LON = max(MAP, key=lambda coord: coord[0])[0]
MAX_LAT = max(MAP, key=lambda coord: coord[1])[1]

LON_RANGE = MAX_LON - MIN_LON
LAT_RANGE = MAX_LAT - MIN_LAT

WIDTH = 640
HEIGHT = int(1.5 * WIDTH * LAT_RANGE / LON_RANGE)

# L = grayscale 8-bit
SCREEN = Image.new("L", (WIDTH, HEIGHT), color=255)
DRAW = ImageDraw.Draw(SCREEN)
MOVING = Image.open("moving.png")
STOPPED = Image.open("stopped.png")


OVERPASS_QUERY = f"""
[out:json];
(
  way[place=island]({MIN_LAT}, {MIN_LON}, {MAX_LAT}, {MAX_LON});
  rel[place=island]({MIN_LAT}, {MIN_LON}, {MAX_LAT}, {MAX_LON});
);
convert item ::=::,::geom=geom(),_osm_type=type(); // magic
out geom qt;
"""


def latlon_to_xy(lat: float, lon: float) -> (float, float):
    x = WIDTH * (lon - MIN_LON) / LON_RANGE
    y = HEIGHT - (HEIGHT * (lat - MIN_LAT) / LAT_RANGE)
    return (x, y)


def draw_coastlines(coastlines):
    def linestring(item):
        screen_coords = []
        coords = item["coordinates"]
        for lon, lat in coords:
            screen_coords.append(latlon_to_xy(lat, lon))
        DRAW.line(screen_coords, fill=200, width=1, joint="curve")

    for sequence in coastlines["elements"]:
        item = sequence["geometry"]
        t = item["type"]
        if t == "LineString":
            linestring(item)
        elif t == "GeometryCollection":
            for i in item["geometries"]:
                linestring(i)
        else:
            raise


def paste(src: Image, dest_x, dest_y):
    grayscale_version = src.convert("L")
    center_x = src.width / 2
    center_y = src.height / 2

    for x in range(src.width):
        for y in range(src.height):
            alpha = src.getpixel((x, y))[-1]
            # PIL alpha compositing cannot be used directly since the images aren't both RGBA.
            # this copies a pixel value to the screen only if the alpha channel in the original is nontransparent.
            if alpha > 0:
                DRAW.point(
                    (dest_x + x - center_x, dest_y + y - center_y),
                    fill=grayscale_version.getpixel((x, y)),
                )


def main():
    filename = f"coastlines-{hash((MIN_LAT, MIN_LON, MAX_LAT, MAX_LON))}.json"
    try:
        with open(filename) as f:
            coastlines = simdjson.load(f)
    except FileNotFoundError:
        print("Downloading coastlines")
        coastlines = requests.get(OVERPASS, {"data": OVERPASS_QUERY}).json()
        with open(filename, "w") as f:
            simdjson.dump(coastlines, f)

    draw_coastlines(coastlines)

    boats = requests.get(VESSELS).json()["vessellist"]
    boats = filter(lambda boat: boat["route"] == "ANA-SJ", boats)
    boats = filter(lambda boat: boat["aterm_id"] != "", boats)
    for boat in boats:
        boat_lat = boat["lat"]
        boat_lon = boat["lon"]

        x, y = latlon_to_xy(boat_lat, boat_lon)

        label = f"{boat['name'][:3].upper()} {boat['pos']}"
        if boat["eta"] == "Calculating":
            label += f" {boat['lastdock'][0]}->{boat['aterm'][0]} departing at {boat['nextdep']}"
            # the frickin image is not centered lol
            paste(STOPPED, x - 2, y)
        else:
            label += f" {boat['lastdock'][0]}->{boat['aterm'][0]} ETA {boat['eta']}"
            paste(MOVING.rotate(-boat["head"]), x, y)
        DRAW.point((x, y), fill=0)
        DRAW.text((x, y), label, anchor="mm")

    SCREEN.save("map.png", "PNG")


if __name__ == "__main__":
    main()
\ No newline at end of file

A  => moving.png +0 -0
A  => requirements.txt +3 -0
@@ 1,3 @@
pillow
pysimdjson`
requests
\ No newline at end of file

A  => stopped.png +0 -0