if not modules then modules = { } end modules ['m-openstreetmap'] = { version = 1.001, comment = "companion to m-openstreetmap.mkxl", author = "Hans Hagen, PRAGMA-ADE, Hasselt NL", copyright = "PRAGMA ADE / ConTeXt Development Team", license = "see context related readme files" } local sin, cos, floor, ceil = math.sin, math.cos, math.floor, math.ceil local formatters = string.formatters local concat, tohash, sortedhash, setmetatableindex, insert = table.concat, table.tohash, table.sortedhash, table.setmetatableindex, table.insert local xmlcollected, xmlparent, xmlfirst, xmlfilter = xml.collected, xml.parent, xml.first, xml.filter local P, S, Cs, lpegmatch = lpeg.P, lpeg.S, lpeg, lpeg.match local openstreetmap = { } moduledata.openstreetmap = openstreetmap local report = logs.reporter("openstreetmap") -- At one of the bachotex meetings Mojca and I sat down to render the bachotek camp site -- as available in openstreetmap. Of course that was a limited setup and after figuring -- out some details it went well. Of course we used metapost. -- -- Years later in 2021 on the mailing list there was some discussion on country outlines -- and disappointed by the quality of what was referred to I remembered the openstreetmap -- adventure. I found that I could export my hometown from the web page so I started from -- that: just stepwise seeing what 'ways' passed and assigning them colors. Just as with -- the bachotex drawing. -- -- Then I went searching for what tags actually were available and ran into: -- -- https://github.com/openstreetmap/osm2pgsql/blob/master/docs/lua.md -- -- I'm not sure where the script is used, and there are dead links on these pages but some -- information can be found in that file so I could combine our findings with these. There -- is a whole infrastructure out there with impressive machinery, style sheet generation -- etc. but we don't need that here. Also, we don't need to play routes. -- -- Colors are one thing (not too hard with a k/v mapping), but a nest hurdle is to decide -- what is a polygon. Actually I found that just checking on something being closed is -- rather ok. There are a few extra ones. But we can also use the list in the file mentioned. -- We probably need to check it occasionally with the original, although it looks quite -- stable. local polygons = tohash { "abandoned:aeroway", "abandoned:amenity", "abandoned:building", "abandoned:landuse", "abandoned:power", "aeroway", "allotments", "amenity", "area:highway", "craft", "building", "building:part", "club", "golf", "emergency", "harbour", "healthcare", "historic", "landuse", "leisure", "man_made", "military", "natural", "office", "place", "power", "public_transport", "shop", "tourism", "water", "waterway", "wetland", -- "bridge", } -- The amenity tag is sort of overloading the main tag so we put it in the main -- hash. This might change. -- -- The stacking order is important and we just started with processing the tags -- in a certain order. However, in \LUAMETATEX\ we can now use stacking so that -- makes for a nice test. At the cost of a bit more metapost code and conversion -- time we can use that. Anyway, this is the list we use and it's an adaption -- from the bachitex one. local order = { "landuse", "leisure", "natural", -- "geological", "water", "amenity", "building", "barrier", "man_made", "bridge", "historic", "military", -- "office", -- "craft", -- "emergency", -- "healthcare", -- "place", -- "power", -- "shop", -- "sport", -- "telecom", -- "publictransport", "waterway", "highway", "railway", "aeroway", "aerialway", -- "tourism", "boundary", -- overlaps ! not okay for hasselt.osm -- "route", } -- From the mentioned lua file we get the stacking (funny numbers are used): -- In Hasselt we have a combination, highway should win : -- -- -- local stacking = { highway = { motorway = 180, trunk = 170, primary = 160, secondary = 150, tertiary = 140, residential = 130, unclassified = 130, road = 130, living_street = 120, pedestrian = 110, raceway = 100, motorway_link = 140, trunk_link = 130, primary_link = 120, secondary_link = 110, tertiary_link = 100, service = 150, track = 110, path = 100, footway = 100, bridleway = 100, cycleway = 100, steps = 190, platform = 190, }, railway = { rail = 140, subway = 120, narrow_gauge = 120, light_rail = 120, funicular = 120, preserved = 120, monorail = 120, miniature = 120, turntable = 120, tram = 110, disused = 100, construction = 100, platform = 190, }, aeroway = { runway = 160, taxiway = 150, }, boundary = { administrative = 0, }, -- bridge = { -- yes = 103, -- movable = 102, -- viaduct = 103, -- }, } setmetatableindex(stacking,function(t,k) local v = setmetatableindex(function(t,k) t[k] = false return false end) t[k] = v return v end) -- reservoir_covered: @ bachotex local colors = { -- these concern details: amenity = { arts_centre = true, bar = true, bicycle_parking = true, college = true, courthouse = true, fountain = true, hospital = true, kindergarten = true, marketplace = true, parking = true, parking_space = true, pharmacy = true, place_of_worship = true, police = true, restaurant = true, school = true, shower = true, social_facility = true, toilets = true, townhall = true, -- university = true, -- no, it will mark all red .. maybe some other color so we need stacking -- atm = true, bank = true, -- bbq = true, bicycle_parking = true, bicycle_repair_station = true, cafe = true, -- car_sharing = true, car_wash = true, -- charging_station = true, childcare = true, clinic = true, -- clock = true, clubhouse = true, college = true, community_centre = true, -- compressed_air = true, computer_lab = true, -- drinking_water = true, events_venue = true, fast_food = true, fire_station = true, fountain = true, fuel = true, -- ice_cream = true, library = true, mailroom = true, -- microwave = true, -- parking_entrance = true, -- parking_space = true, pharmacy = true, place_of_worship = true, -- post_box = true, post_office = true, recycling = true, research_institute = true, -- social_facility = true, theatre = true, -- vending_machine = true, -- waste_basket = true, -- waste_disposal = true, wellness_centre = true, }, -- these are basic: boundary = { aboriginal_lands = true, national_park = true, protected_area = true, administrative = true, }, building = { apartments = true, bandstand = true, cathedral = true, civic = true, commercial = true, construction = true, -- no roadtrip garage = true, government = true, hospital = true, house = true, houseboat = true, hut = true, industrial = true, kiosk = true, public = true, residential = true, retail = true, roof = true, school = true, shed = true, townhall = true, yes = true, university = true, dormitory = true, barn = true, bridge = true, detached = true, farm_auxiliary = true, grandstand = true, greenhouse = true, kindergarten = true, parking = true, stable = true, stadium = true, toilets = true, }, emergency = { designated = true, destination = true, no = true, official = true, yes = true, }, man_made = { breakwater = true, bridge = true, instrument = true, pier = true, quay = true, tower = true, windmill = true, cutline = true, embankment = true, groyne = true, pipeline = true, }, natural = { arete = true, cliff = true, earth_bank = true, ridge = true, sand = true, scrub = true, tree_row = true, water = true, wetland = true, wood = true, fault = true, }, barrier = { chain = true, city_wall = true, fence = true, gate = true, guard_rail = true, hedge = true, retaining_wall = true, wall = true, yes = true, }, leisure = { garden = true, ice_rink = true, marina = true, park = true, pitch = true, playground = true, slipway = true, sports_centre = true, track = true, beach = true, }, boat = { yes = true, }, landuse = { allotments = true, cemetery = true, commercial = true, construction = true, forest = true, grass = true, industrial = true, meadow = true, residential = true, static_building = true, village_green = true, }, ["bridge:support"] = { pier = true, }, golf = { -- funny, this category cartpath = true, hole = true, path = true, }, area = { yes = true, }, bridge = { yes = true, movable = true, viaduct = true, }, agricultural = { yes = true, no = true, }, historic = { citywalls = true, }, tourism = { yes = true, }, power = { cable = true, line = true, minor_line = true, }, junction = { yes = true, }, water = { river = true, basin = true, }, -- these indicate routes: highway = { corridor = true, bridleway = true, cycleway = true, footway = true, living_street = true, motorway = true, motorway_link = true, path = true, pedestrian = true, platform = true, primary = true, primary_link = true, raceway = true, residential = true, rest_area = true, road = true, -- secondary = true, secondary_link = true, service = true, services = true, steps = true, tertiary = true, tertiary_link = true, track = true, trunk = true, trunk_link = true, unclassified = true, }, waterway = { canal = true, derelict_canal = true, ditch = true, drain = true, river = true, stream = true, tidal_channel = true, wadi = true, weir = true, }, railway = { construction = true, disused = true, funicular = true, light_rail = true, miniature = true, monorail = true, narrow_gauge = true, platform = true, preserved = true, rail = true, station = true, subway = true, tram = true, turntable = true, }, aeroway = { runway = true, taxiway = true, }, aerialway = { station = true, }, } -- We use this 'inside' knowledge encoded in the mentioned script to avoid bad fills -- (for instance polygons can be unconnected and unordered). We define the table a bit -- different. local forcedlines = { golf = { "cartpath", "hole", "path" }, emergency = { "designated", "destination", "no", "official", "yes" }, historic = { "citywalls" }, leisure = { "track", "slipway" }, man_made = { "breakwater", "cutline", "embankment", "groyne", "pipeline" }, natural = { "cliff", "earth_bank", "tree_row", "ridge", "arete", "fault" }, power = { "cable", "line", "minor_line" }, tourism = { "yes"}, waterway = { "canal", "derelict_canal", "ditch", "drain", "river", "stream", "tidal_channel", "wadi", "weir" }, } do local function never(t,k) t[k] = false return false end for k, v in next, forcedlines do forcedlines[k] = setmetatableindex(tohash(v),never) end setmetatableindex(forcedlines,function(t,k) local v = setmetatableindex(never) t[k] = v return v end) end -- For fast checking we apply the usual context tricks: for k, v in next, colors do for kk, vv in next, v do v[kk] = "osm:" .. k .. ":" .. kk end end -- We draw and fill but sometimes we do both: local lines = { amenity = true, building = true, man_made = true, boat = true, } -- When checking labels for a while I had a blacklist of tags (keys) but -- dropped that when most was done. So, we're now ready for the real deal: -- this only works for positive numbers local f_f_degree_to_str = formatters["%d°%d’%.0f”"] local function f_degree_to_str(num) local deg = floor(num) num = (num - deg) * 60 local min = floor(num) num = (num - min) * 60 local sec = num return f_f_degree_to_str(deg,min,sec) end -- local f_pattern = formatters["/osm/(way|relation)[@visible='true']/tag[@k='%s']"] local f_pattern = formatters["/osm/(way|relation)[@visible!='false']/tag[@k='%s']"] local f_way = formatters["/osm/way[@id='%s']"] local f_relation = formatters["/osm/relation[@id='%s']"] -- We could reuse paths and concat 0,n in the call but when testing it the gain was -- not much so I went for readability. local f_draw = formatters['D %--t W "%s";'] local f_fill = formatters['F %--t--C W "%s";'] local f_both = formatters['P := %--t--C; F P W "%s"; D P W "white" L 2;'] local f_draw_s = formatters['D %--t W "%s" L %s;'] local f_fill_s = formatters['F %--t--C W "%s" L %s;'] local f_both_s = formatters['P := %--t--C; F P W "%s"; D P W "white" L %s;'] local f_nodraw = formatters['ND %--t;'] local f_nofill = formatters['NF %--t--C;'] local f_nodraw_s = formatters['ND %--t;'] local f_nofill_s = formatters['NF %--t--C;'] local f_background = formatters['F %--t -- C W "osm:background";'] local f_bounds = formatters['setbounds currentpicture to %--t--C withstacking (0,250);'] local f_clipped = formatters['clip currentpicture to %--t--C withstacking (0,250);'] -- For now no labels are printed, also because that's now what we use this for. At -- some point I will provide some hooks to put text at coordinates. -- local f_draw_p = formatters["path p ; p := %--t ; D p W %s;"] -- local f_fill_p = formatters["path p ; p := %--t -- C ; F p W %s;"] -- local f_textext = formatters['draw (textext("\\bf %s") scaled 0.35) shifted center p withcolor white;'] -- Grids were also part of the original code, so I kept that. It's done a bit more -- efficient by using a single path. local f_draw_grid_b = formatters['nodraw %--t L 100;'] local f_draw_grid_e = formatters['dodraw origin dashed %s withcolor "osm:grid" withpen pencircle scaled %N L 100;'] local f_label_lat = formatters['label.urt((textext("\\infofont\\strut %s")), %s shifted (3,0)) withcolor white L 100;'] local f_label_lon = formatters['label.top((textext("\\infofont\\strut %s")), %s shifted (0,3)) withcolor white L 100;'] local beginmp = [[ begingroup ; pickup pencircle scaled 1 ; save P ; path P ; save D ; let D = draw ; save F ; let F = fill ; save C ; let C = cycle ; save W ; let W = withcolor ; save L ; let L = withstacking ; save ND ; let ND = nodraw ; save DD ; let DD = dodraw ; save NF ; let NF = nofill ; save DF ; let DF = dofill ; ]] local endmp = [[ endgroup; ]] local p_strip = lpeg.Cs( ( -- (P([["))^1 * P("/>") * S("\n\r\t\f ")^0) / "" -- + ( ( P("version") + P("changeset") + P("timestamp") + P("user") + P("uid") ) * P('="') * (1-P('"'))^0 * P('"') ) / "" + P(">") * (S("\n\r\t\f ")^1 / "") * P("<") + P('visible="true"') / "" + P(1) )^1 ) function openstreetmap.convert(specification) local starttime = os.clock() local filename = specification.filename if not io.exists(filename) then return end report("processing file %a",filename) -- we can consider stripping crap first -- local root = xml.load(filename) local root = io.loaddata(filename) local olds = #root root = lpegmatch(p_strip,root) local news = #root report("original size %i bytes, stripped down to %i bytes",olds,news) root = xml.convert(root) if root.error then report("error in xml",olds,news) return else report("xml data loaded") end local bounds = xmlfirst(root,"/osm/bounds") if not bounds then return end local usercolors = specification.used local usedcolors = table.copy(colors) if usercolors then for k, v in next, usercolors do local u = usedcolors[k] if not u then -- error elseif v == false then usedcolors[k] = false -- for k in next, u do -- u[k] = false -- end elseif type(v) == "string" then for k in next, u do u[k] = v end elseif type(v) == "table" then for kk, vv in next, v do if vv == false then u[kk] = false elseif type(vv) == "string" then u[kk] = vv end end end end end -- inspect(usedcolors) local minlat = bounds.at.minlat local minlon = bounds.at.minlon local maxlat = bounds.at.maxlat local maxlon = bounds.at.maxlon local midlat = 0.5 * (minlat + maxlat) local deg_to_rad = math.pi / 180.0 local scale = 3600 -- vertical scale: 1" = 1cm -- local function f_pair(lon, lat) -- return formatters("(%.3Ncm,%.3Ncm)", (lon - minlon) * scale * cos(midlat * deg_to_rad), (lat-minlat) * scale) -- end local f_f_pair = formatters["(%.3Ncm,%.3Ncm)"] local function f_pair(lon, lat) return f_f_pair((lon - minlon) * scale * cos(midlat * deg_to_rad), (lat-minlat) * scale) end local rendering = table.tohash(order) local coordinates = { } local ways = { } local result = { } local r = 0 local done = { } local missing = false -- setmetatableindex("table") local layers = { } local areas = { } for c in xmlcollected(root,"/osm/node") do local a = c.at coordinates[a.id] = a end for c in xmlcollected(root,"/osm/way") do ways[c.at.id] = c end for c in xml.collected(root,"tag[@k='area']") do areas[c] = c.at.v end for c in xml.collected(root,"tag[@k='layer']") do layers[c] = c.at.v end -- Collecting is more a private option. It doesn't save much on the output -- but looks better with transparency, which makes no sense using anyway. local collected = specification.collect and setmetatableindex(function(t,k) local v = setmetatableindex(function(t,k) local v = { draw = setmetatableindex("table"), fill = setmetatableindex("table"), } t[k] = v return v end) t[k] = v return v end) or false local function drawshapes(what,order) -- see bachotex rendering for an example -- also layer and polygon function xml.expressions.osm(k) return usedcolors[k] end local function getcolor(r) local t = xmlfirst(r,"/tag[osm(@k)]") if t then local at = t.at local v = at.v if v ~= "no" then local k = at.k local col = usedcolors[k][v] -- we need an example: if layers[r] then print(layers[r]) end if col then -- todo : trace colors and stacking return k, col, lines[k], stacking[k][v], forcedlines[k][v] elseif missing then missing[k][v] = (missing[k][v] or 0) + 1 end end end end local function addpath(r, p, n) -- if done[r] then -- print("AGAIN") -- else for c in xmlcollected(r,"/nd") do local coordinate = coordinates[c.at.ref] if coordinate then n = n + 1 p[n] = f_pair(coordinate.lon, coordinate.lat) end end -- done[r] = true -- end return p, n end local checkpath = collected and function(parent,p,n) local what, color, both, stacking, forced = getcolor(parent) if what and rendering[what] then local where = collected[stacking or order] if not polygons[what] or forced or areas[parent] == "no" then insert(where[color] .draw, f_nodraw(p)) elseif both then insert(where[color] .fill, f_nofill(p)) insert(where["white"].draw, f_nodraw(p)) else insert(where[color] .fill, f_nofill(p)) end end end or function(parent,p,n) local what, color, both, stacking, forced = getcolor(parent) if what and rendering[what] then r = r + 1 -- if not stacking then -- stacking = order -- end if not polygons[what] or forced or areas[parent] == "no" then result[r] = stacking and f_draw_s(p,color,stacking) or f_draw(p,color) elseif both then result[r] = stacking and f_both_s(p,color,stacking) or f_both(p,color) else result[r] = stacking and f_fill_s(p,color,stacking) or f_fill(p,color) end end end -- There are ways and relations. Relations can have members that point to -- ways but also relations. My impression is that we can stick to way members -- but I'll deal with that when needed. for c in xmlcollected(root,f_pattern(what)) do local parent = xmlparent(c) local tag = parent.tg if tag == "way" then local p, n = addpath(parent, { }, 0) if n > 1 then checkpath(parent,p,n) end elseif tag == "relation" then if xmlfilter(parent,"xml://tag[@k='type' and (@v='multipolygon' or @v='boundary' or @v='route')]") then local what, color, both, stacking, forced = getcolor(parent) if rendering[what] then local p, n = { }, 0 for m in xmlcollected(parent,"/member[(@type='way') and (@role='outer')]") do -- local f = xmlfirst(root,f_way(m.at.ref)) local f = ways[m.at.ref] if f then p, n = addpath(f,p,n) end end if n > 1 then checkpath(parent,p,n) end end else for m in xmlcollected(parent,"/member[@type='way']") do -- local f = xmlfirst(root,f_way(m.at.ref)) local f = ways[m.at.ref] if f then local p, n = addpath(f, { }, 0) if n > 1 then checkpath(parent,p,n) end end end end end end end -- As with the other latitude and longitude mapping calculations the next magick -- comes from Mojca. local function drawgrid() local lat0 = ceil (3600*minlat) local lat1 = floor(3600*maxlat) local pen = tonumber(specification.griddot) or 1.5 local lat local labels = { } for i=lat0,lat1 do lat = i/3600 local p = { f_pair(minlon,lat), f_pair(maxlon,lat), } r = r + 1 result[r] = f_draw_grid_b(p) if i ~= lat0 and i ~= lat1 then labels[#labels+1] = f_label_lat(f_degree_to_str(lat),p[1]) end end local lon0 = ceil (1800*minlon)*2 local lon1 = floor(1800*maxlon)*2 local lon for i=lon0,lon1,2 do lon=i/3600 local p = { f_pair(lon, minlat), f_pair(lon, maxlat), } r = r + 1 result[r] = f_draw_grid_b(p) if i ~= lon0 and i ~= lon1 then labels[#labels+1] = f_label_lon(f_degree_to_str(lon),p[1]) end end r = r + 1 result[r] = f_draw_grid_e("withdots", pen) r = r + 1 result[r] = concat(labels) end -- We add a background first and clip later. Beware: There can be substantial bits -- outside the clip path (like rivers) but because paths are not that detailed we -- don't waste time on building a cycle. We could check if points are outside the -- boundingbox and then use the metapost buildpath macro .. some day. local boundary = { f_pair(minlon,minlat), f_pair(maxlon,minlat), f_pair(maxlon,maxlat), f_pair(minlon,maxlat), } r = r + 1 result[r] = beginmp r = r + 1 result[r] = f_background(boundary) -- r = r + 1 result[r] = "drawoptions (withtransparency (1,.5)) ;" -- use stacking instead for i=1,#order do local o = order[i] if usedcolors[o] then drawshapes(o,i) end end if specification.grid == "dots" then drawgrid() end if collected then local f_flush = formatters[') W "%s" L %s;'] for stacking, colors in sortedhash(collected) do for color, bunch in next, colors do local draw = bunch.draw local fill = bunch.fill if fill and #fill > 0 then r = r + 1 result[r] = "draw image (" r = r + 1 result[r] = concat(fill) r = r + 1 result[r] = 'DF origin--cycle;' r = r + 1 result[r] = f_flush(color,stacking) ; end if draw and #draw > 0 then r = r + 1 result[r] = "draw image (" r = r + 1 result[r] = concat(draw) r = r + 1 result[r] = 'DD origin;' r = r + 1 result[r] = f_flush(color,stacking+1) ; end end end end -- r = r + 1 result[r] = f_bounds(boundary) r = r + 1 result[r] = f_clipped(boundary) r = r + 1 result[r] = endmp if missing then inspect(missing) end result = concat(result) report("%s characters metapost code, preprocessing time %0.3f seconds",#result,os.clock()-starttime) return result end function mp.lmt_do_openstreetmap() local specification = metapost.getparameterset("openstreetmap") return openstreetmap.convert(specification) end