432 lines
22 KiB
Python
432 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Dependency-free JSON -> editable SVG renderer for thematic transit / timeline maps."""
|
|
import argparse
|
|
import heapq
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
import unicodedata
|
|
from collections import defaultdict
|
|
from html import escape
|
|
from pathlib import Path
|
|
|
|
FONT = "'PingFang SC','Microsoft YaHei','Noto Sans CJK SC',Arial,sans-serif"
|
|
PALETTE = ['#9254c8', '#b51f3f', '#ef6a55', '#ed9c2b', '#14805e', '#31a88f', '#1685b8', '#264fa4']
|
|
|
|
|
|
def number(v, name):
|
|
if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v):
|
|
raise ValueError(f'{name} must be a finite number')
|
|
return v
|
|
|
|
|
|
def interp(points, value):
|
|
if value <= points[0][0]:
|
|
return points[0][1]
|
|
for (a, x), (b, y) in zip(points, points[1:]):
|
|
if value <= b:
|
|
return x + (y-x) * (value-a) / (b-a)
|
|
return points[-1][1]
|
|
|
|
|
|
def smooth(points, value):
|
|
"""Cubic easing with derivative, so station positions lie on the rendered curve."""
|
|
if value <= points[0][0] or value >= points[-1][0]:
|
|
return (points[0][1] if value <= points[0][0] else points[-1][1]), 0
|
|
for (a,x),(b,y) in zip(points,points[1:]):
|
|
if value <= b:
|
|
t=(value-a)/(b-a)
|
|
return x+(y-x)*t*t*(3-2*t), (y-x)*6*t*(1-t)/(b-a)
|
|
|
|
|
|
def pairs(value, name):
|
|
if not isinstance(value, list) or not value:
|
|
raise ValueError(f'{name} must be a nonempty list of [input, output] pairs')
|
|
for p in value:
|
|
if not isinstance(p, list) or len(p) != 2:
|
|
raise ValueError(f'{name}: expected [input, output]')
|
|
for x in p:
|
|
number(x, name)
|
|
if any(a[0] >= b[0] for a, b in zip(value, value[1:])):
|
|
raise ValueError(f'{name}: inputs must be strictly increasing')
|
|
return value
|
|
|
|
|
|
def width(text, size):
|
|
# Conservative estimate; export is live text. Browser/font QA remains necessary.
|
|
return sum(size * (1.0 if unicodedata.east_asian_width(c) in 'WF' else .60) for c in str(text))
|
|
|
|
|
|
def overlaps(a, b, gap=0):
|
|
return a[0] < b[2]+gap and a[2]+gap > b[0] and a[1] < b[3]+gap and a[3]+gap > b[1]
|
|
|
|
|
|
def bezier(points):
|
|
out = [f'M {points[0][0]:.2f},{points[0][1]:.2f}']
|
|
for (x, y), (xx, yy) in zip(points, points[1:]):
|
|
mid = (y+yy)/2
|
|
out.append(f'C {x:.2f},{mid:.2f} {xx:.2f},{mid:.2f} {xx:.2f},{yy:.2f}')
|
|
return ' '.join(out)
|
|
|
|
|
|
class Renderer:
|
|
def __init__(self, data):
|
|
self.d = data
|
|
self.mode = data.get('mode', 'metro')
|
|
if self.mode not in ('metro', 'timeline'):
|
|
raise ValueError('mode must be metro or timeline')
|
|
self.routes = data.get('routes', [])
|
|
self.nodes = data.get('nodes', [])
|
|
if not self.routes or not self.nodes:
|
|
raise ValueError('nonempty routes and nodes are required')
|
|
for items, kind in ((self.routes, 'route'), (self.nodes, 'node')):
|
|
ids = [r.get('id') for r in items]
|
|
if any(not isinstance(i, str) or not i for i in ids) or len(set(ids)) != len(ids):
|
|
raise ValueError(f'{kind} IDs must be unique nonempty strings')
|
|
self.r = {r['id']: r for r in self.routes}
|
|
self.n = {n['id']: n for n in self.nodes}
|
|
self.members = defaultdict(list)
|
|
for i, r in enumerate(self.routes):
|
|
color = r.setdefault('color', PALETTE[i % len(PALETTE)])
|
|
if not re.fullmatch(r'#[0-9a-fA-F]{6}', color):
|
|
raise ValueError('route colors must be #RRGGBB')
|
|
stops = r.get('stops', [])
|
|
if not stops or len(stops) != len(set(stops)):
|
|
raise ValueError(f"{r['id']}: stops must be nonempty and unique")
|
|
for s in stops:
|
|
if s not in self.n:
|
|
raise ValueError(f"{r['id']}: unknown stop {s}")
|
|
self.members[s].append(r['id'])
|
|
offsets=pairs(r.get('offsets', [[0, 0], [1, 0]]), f"{r['id']}.offsets")
|
|
if offsets[0][0]<0 or offsets[-1][0]>1:
|
|
raise ValueError('offset positions must lie between 0 and 1')
|
|
if any(not self.members[n] for n in self.n):
|
|
raise ValueError('every node must belong to at least one route')
|
|
for link in data.get('links', []):
|
|
if link.get('from') not in self.n or link.get('to') not in self.n:
|
|
raise ValueError('link references an unknown node')
|
|
self.layout = data.get('layout', {})
|
|
self.lane_order = self.layout.get('lane_order', list(self.r))
|
|
if len(self.lane_order) != len(self.r) or set(self.lane_order) != set(self.r):
|
|
raise ValueError('lane_order must contain every route ID exactly once')
|
|
self.lanes = {k: i for i, k in enumerate(self.lane_order)}
|
|
self.w = number(self.layout.get('width', 1320), 'width')
|
|
self.spacing = number(self.layout.get('lane_gap', 86), 'lane_gap')
|
|
self.row_gap = number(self.layout.get('row_gap', 66), 'row_gap')
|
|
self.font = number(self.layout.get('font_size', 17), 'font_size')
|
|
self.top = 430
|
|
self.x0 = (self.w - (len(self.routes)-1)*self.spacing)/2
|
|
if self.x0 < 150 or self.font < 8 or self.row_gap < 36 or self.spacing < 32:
|
|
raise ValueError('canvas too narrow or spacing/font too small; enlarge layout')
|
|
self.paper = data.get('theme', {}).get('paper', '#fbf8f0')
|
|
if not re.fullmatch(r'#[0-9a-fA-F]{6}', self.paper):
|
|
raise ValueError('theme.paper must be #RRGGBB')
|
|
self.parts = []
|
|
self.boxes = []
|
|
self.warnings = []
|
|
self.positions = {}
|
|
self.label_info = []
|
|
self.leaders = []
|
|
self.route_paths = {}
|
|
|
|
def text(self, x, y, text, size=17, fill='#202426', anchor='start', weight=400, **attrs):
|
|
attrs.update(x=f'{x:.2f}', y=f'{y:.2f}', fill=fill, **{'font-size':size, 'text-anchor':anchor, 'font-weight':weight})
|
|
st = ' '.join(f'{k.replace("_", "-")}="{escape(str(v), quote=True)}"' for k, v in attrs.items())
|
|
self.parts.append(f'<text {st}>{escape(str(text))}</text>')
|
|
|
|
def path(self, d, color, stroke=2, **attrs):
|
|
a = ' '.join(f'{k.replace("_", "-")}="{escape(str(v), quote=True)}"' for k, v in attrs.items())
|
|
self.parts.append(f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{stroke}" {a}/>')
|
|
|
|
def circle(self, x, y, radius, fill, stroke='none', sw=0):
|
|
self.parts.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{radius}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>')
|
|
|
|
def rect(self, x, y, w, h, fill, rx=0, stroke='none', sw=0):
|
|
self.parts.append(f'<rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" height="{h:.2f}" rx="{rx}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>')
|
|
|
|
def order(self):
|
|
indegree = dict.fromkeys(self.n, 0)
|
|
adj = defaultdict(set)
|
|
for r in self.routes:
|
|
for a, b in zip(r['stops'], r['stops'][1:]):
|
|
if b not in adj[a]:
|
|
adj[a].add(b)
|
|
indegree[b] += 1
|
|
index = {n['id']: i for i, n in enumerate(self.nodes)}
|
|
def key(n):
|
|
return (number(self.n[n].get('order', index[n]), 'node.order'), index[n], n)
|
|
ready = [key(n) for n, v in indegree.items() if not v]
|
|
heapq.heapify(ready)
|
|
result = []
|
|
while ready:
|
|
_, _, n = heapq.heappop(ready)
|
|
result.append(n)
|
|
for dest in sorted(adj[n], key=lambda v: index[v]):
|
|
indegree[dest] -= 1
|
|
if not indegree[dest]:
|
|
heapq.heappush(ready, key(dest))
|
|
if len(result) != len(self.nodes):
|
|
raise ValueError('route stop orders form a cycle; reconcile orders or split the map')
|
|
return result
|
|
|
|
def setup(self):
|
|
if self.mode == 'metro':
|
|
order = self.order()
|
|
self.h = self.top + max(1, len(order)-1)*self.row_gap + 220
|
|
self.bottom = self.h-220
|
|
for i, nid in enumerate(order):
|
|
y = self.top + i*self.row_gap
|
|
self.positions[nid] = self.node_position(nid, y, i/max(1,len(order)-1))
|
|
else:
|
|
for n in self.nodes:
|
|
number(n.get('time'), f"{n['id']}.time")
|
|
lo, hi = min(n['time'] for n in self.nodes), max(n['time'] for n in self.nodes)
|
|
if lo == hi:
|
|
raise ValueError('timeline needs at least two different times')
|
|
self.scale = pairs(self.layout.get('time_scale', [[lo, 0], [hi, 1]]), 'time_scale')
|
|
if self.scale[0] != [lo, 0] or self.scale[-1] != [hi, 1]:
|
|
raise ValueError('time_scale must start at [minimum_time,0] and end at [maximum_time,1]')
|
|
if any(a[1] >= b[1] for a,b in zip(self.scale,self.scale[1:])):
|
|
raise ValueError('time_scale output fractions must increase')
|
|
self.h = number(self.layout.get('height', 2050), 'height')
|
|
self.bottom = self.h-200
|
|
if self.bottom-self.top < 200:
|
|
raise ValueError('timeline height too small')
|
|
for n in self.nodes:
|
|
t = interp(self.scale, n['time'])
|
|
y = self.top + t*(self.bottom-self.top)
|
|
self.positions[n['id']] = self.node_position(n['id'], y, t)
|
|
for r in self.routes:
|
|
times = [self.n[s]['time'] for s in r['stops']]
|
|
if times != sorted(times):
|
|
raise ValueError(f"{r['id']}: timeline stops must be chronological")
|
|
|
|
def rx(self, rid, t):
|
|
r = self.r[rid]
|
|
return self.x0+self.lanes[rid]*self.spacing+smooth(r.get('offsets', [[0,0],[1,0]]), t)[0]
|
|
|
|
def node_position(self, nid, y, t):
|
|
xs = {rid:self.rx(rid,t) for rid in self.members[nid]}
|
|
return {'y':y, 't':t, 'xs':xs, 'x':sum(xs.values())/len(xs), 'left':min(xs.values())-19, 'right':max(xs.values())+19}
|
|
|
|
def header(self):
|
|
self.text(56, 55, self.d.get('eyebrow','FIELDNOTES / KNOWLEDGE CARTOGRAPHY'), 13, '#606766', weight=600, letter_spacing=2)
|
|
self.text(54, 121, self.d.get('title','Knowledge Map'), 46, weight=700)
|
|
self.text(56, 158, self.d.get('subtitle',''), 17, '#626967')
|
|
self.path(f'M 56,184 H {self.w-56}', '#262b2b', 2)
|
|
groups = {}
|
|
for r in self.routes:
|
|
groups.setdefault(r.get('group', '线路 / ROUTES'), []).append(r)
|
|
columns = max(1,len(groups))
|
|
max_rows = max(map(len,groups.values()))
|
|
if max_rows > 4:
|
|
# Preserve room for arbitrary route counts instead of clipping the legend.
|
|
extra = (max_rows-4)*25
|
|
self.top += extra
|
|
self.bottom += extra
|
|
self.h += extra
|
|
for p in self.positions.values():
|
|
p['y'] += extra
|
|
for i,(name,routes) in enumerate(groups.items()):
|
|
x = 56+i*(self.w-112)/columns
|
|
self.text(x, 216, name, 15, weight=600)
|
|
for j,r in enumerate(routes):
|
|
y = 244+j*25
|
|
code = r.get('code',str(self.lanes[r['id']]+1))
|
|
self.circle(x+8,y-5,8,r['color'])
|
|
self.text(x+8,y-1,code,10,self.paper,'middle',700)
|
|
self.text(x+24,y,r.get('label',r['id']),13)
|
|
|
|
def background(self):
|
|
if self.mode != 'timeline':
|
|
return
|
|
eras = self.d.get('eras',[])
|
|
for i,era in enumerate(eras):
|
|
a = number(era['start'], 'era.start')
|
|
b = number(era['end'], 'era.end')
|
|
if b <= a or a < self.scale[0][0] or b > self.scale[-1][0]:
|
|
raise ValueError('era interval is invalid or outside timeline')
|
|
y = self.top+interp(self.scale,a)*(self.bottom-self.top)
|
|
yy = self.top+interp(self.scale,b)*(self.bottom-self.top)
|
|
self.rect(40,y,self.w-80,yy-y,'#eeede3' if i%2==0 else '#f6f3ea')
|
|
self.text(57,y+24,era.get('label',''),12,'#7b817b')
|
|
for tick in self.d.get('ticks',[p[0] for p in self.scale]):
|
|
tick = number(tick,'tick')
|
|
if not self.scale[0][0] <= tick <= self.scale[-1][0]:
|
|
raise ValueError('tick outside timeline')
|
|
y = self.top+interp(self.scale,tick)*(self.bottom-self.top)
|
|
self.path(f'M 100,{y:.2f} H {self.w-95}', '#c9cec6', .7, stroke_dasharray='2 6')
|
|
self.text(90,y+5,f'{tick:g}',12,'#7b817b','end')
|
|
self.text(self.w-85,y+5,f'{tick:g}',12,'#7b817b')
|
|
|
|
def links(self):
|
|
for link in self.d.get('links',[]):
|
|
a, b = self.positions[link['from']],self.positions[link['to']]
|
|
self.parts.append(f'<g><title>{escape(link.get("label", "关联 / ASSOCIATION"))}</title>')
|
|
self.path(bezier([(a['x'],a['y']),(b['x'],b['y'])]),'#8b9691',1.3,opacity='.6',marker_end='url(#arrow)')
|
|
self.parts.append('</g>')
|
|
|
|
def tracks(self):
|
|
self.ends = []
|
|
for r in self.routes:
|
|
stops = [self.positions[s] for s in r['stops']]
|
|
rid=r['id']
|
|
offsets=r.get('offsets',[[0,0],[1,0]])
|
|
levels=sorted({stops[0]['t'],stops[-1]['t']} | {t for t,x in offsets if stops[0]['t']<t<stops[-1]['t']})
|
|
points=[(self.rx(rid,t), self.top+t*(self.bottom-self.top)) for t in levels]
|
|
if any(x<40 or x>self.w-40 for x,y in points):
|
|
self.warnings.append({'kind':'route_outside','route':rid})
|
|
start=(points[0][0],points[0][1]-55)
|
|
end=(points[-1][0],points[-1][1]+55)
|
|
d=f'M {start[0]:.2f},{start[1]:.2f} L {points[0][0]:.2f},{points[0][1]:.2f}'
|
|
for i,((x,y),(xx,yy)) in enumerate(zip(points,points[1:])):
|
|
dt=levels[i+1]-levels[i]
|
|
dx=smooth(offsets,levels[i])[1]*dt/3
|
|
dxx=smooth(offsets,levels[i+1])[1]*dt/3
|
|
d+=f' C {x+dx:.2f},{y+(yy-y)/3:.2f} {xx-dxx:.2f},{yy-(yy-y)/3:.2f} {xx:.2f},{yy:.2f}'
|
|
d+=f' L {end[0]:.2f},{end[1]:.2f}'
|
|
self.route_paths[rid]=d
|
|
self.parts.append(f'<g data-route="{escape(r["id"],quote=True)}"><title>{escape(r.get("label",r["id"]))}</title>')
|
|
sw = 15 if self.mode=='metro' else 6
|
|
self.path(d,self.paper,sw+5,stroke_linecap='round')
|
|
self.path(d,r['color'],sw,stroke_linecap='round')
|
|
self.parts.append('</g>')
|
|
self.ends.extend((x,y,r) for x,y in (start,end))
|
|
for x,y,r in self.ends:
|
|
self.circle(x,y,17 if self.mode=='metro' else 12,r['color'],self.paper,3)
|
|
self.text(x,y+5,r.get('code',str(self.lanes[r['id']]+1)),13,self.paper,'middle',700)
|
|
self.boxes.append((x-20,y-20,x+20,y+20,r['id'],'endpoint'))
|
|
|
|
def stations(self):
|
|
for nid,p in self.positions.items():
|
|
n = self.n[nid]
|
|
self.parts.append(f'<g data-node="{escape(nid,quote=True)}"><title>{escape(n.get("label",nid))}</title>')
|
|
multi = len(p['xs'])>1
|
|
if multi:
|
|
self.rect(p['left'],p['y']-17,p['right']-p['left'],34,self.paper,17,'#202426',2)
|
|
for rid,x in p['xs'].items():
|
|
self.circle(x,p['y'],7 if self.mode=='metro' else 6,self.paper,self.r[rid]['color'],3)
|
|
if n.get('major'):
|
|
self.circle(x,p['y'],3,self.r[rid]['color'])
|
|
self.boxes.append((p['left']-2,p['y']-19,p['right']+2,p['y']+19,nid,'station'))
|
|
# Unrelated routes between members explicitly pass OVER the interchange.
|
|
if multi:
|
|
y = p['y']
|
|
for r in self.routes:
|
|
rid = r['id']
|
|
if rid in p['xs']:
|
|
continue
|
|
active = [self.positions[s]['y'] for s in r['stops']]
|
|
x = self.rx(rid,p['t'])
|
|
if min(active)-55 <= y <= max(active)+55 and p['left'] < x < p['right']:
|
|
clip=f'overpass-{len(self.warnings)}'
|
|
self.parts.append(f'<defs><clipPath id="{clip}"><rect x="{x-20:.2f}" y="{y-23:.2f}" width="40" height="46"/></clipPath></defs>')
|
|
self.path(self.route_paths[rid],self.paper,21,clip_path=f'url(#{clip})')
|
|
self.path(self.route_paths[rid],r['color'],15 if self.mode=='metro' else 6,clip_path=f'url(#{clip})')
|
|
self.warnings.append({'kind':'overpass','node':nid,'route':rid})
|
|
self.parts.append('</g>')
|
|
|
|
def labels(self):
|
|
for nid in sorted(self.positions,key=lambda s:(not self.n[s].get('major',False), self.positions[s]['y'])):
|
|
p,n = self.positions[nid],self.n[nid]
|
|
main = str(n.get('label',nid))
|
|
sub = str(n.get('detail',''))
|
|
size = self.font
|
|
tw = max(width(main,size),width(sub,size-4))
|
|
th = 41 if sub else 23
|
|
preferred = n.get('label_side','right' if p['x']>=self.w/2 else 'left')
|
|
if preferred not in ('right','left'):
|
|
raise ValueError('label_side must be left or right')
|
|
dy_pin = n.get('label_dy')
|
|
dys = [number(dy_pin,'label_dy')] if dy_pin is not None else [0,-26,26,-52,52,-78,78,-104,104]
|
|
choices=[]
|
|
for side in (preferred, 'left' if preferred=='right' else 'right'):
|
|
for dy in dys:
|
|
active_x=[]
|
|
for r in self.routes:
|
|
active=[self.positions[s]['y'] for s in r['stops']]
|
|
if min(active)-55<=p['y']<=max(active)+55:
|
|
active_x.append(self.rx(r['id'],p['t']))
|
|
near = p['right']+17 if side=='right' else p['left']-17-tw
|
|
gutter = max(active_x)+36 if side=='right' else min(active_x)-36-tw
|
|
for x in dict.fromkeys([near,gutter]):
|
|
y = p['y']-th/2+dy
|
|
box=(x-4,y-3,x+tw+4,y+th+3)
|
|
bounds = x<110 or x+tw>self.w-110 or y<self.top-30 or y+th>self.h-115
|
|
hits=sum(overlaps(box,b[:4],5) for b in self.boxes)
|
|
railhits=sum(box[0]-9<xx<box[2]+9 for xx in active_x)
|
|
score = bounds*100000+hits*10000+railhits*350+abs(dy)*1.5+abs(x-near)*.12+(0 if side==preferred else 30)
|
|
choices.append((score,side,dy,x,y,box,hits,bounds,railhits))
|
|
_,side,dy,x,y,box,hits,bounds,railhits=min(choices,key=lambda c:c[0])
|
|
if hits or bounds or railhits:
|
|
self.warnings.append({'kind':'label_needs_review','node':nid,'collisions':hits,'outside':bool(bounds),'route_crossings':railhits})
|
|
anchor='start' if side=='right' else 'end'
|
|
tx=x if side=='right' else x+tw
|
|
near=p['right']+17 if side=='right' else p['left']-17-tw
|
|
if abs(dy)>2 or abs(x-near)>2:
|
|
edge=p['right'] if side=='right' else p['left']
|
|
target=x-7 if side=='right' else x+tw+7
|
|
self.leaders.append(f'<path d="M {edge:.2f},{p["y"]:.2f} L {target:.2f},{y+th/2:.2f}" fill="none" stroke="#939b94" stroke-width=".9"/>')
|
|
self.text(tx,y+18,main,size,anchor=anchor,weight=650 if n.get('major') else 500,paint_order='stroke',stroke=self.paper,stroke_width=4,stroke_linejoin='round')
|
|
if sub:
|
|
self.text(tx,y+37,sub,size-4,'#6c746e',anchor,paint_order='stroke',stroke=self.paper,stroke_width=3)
|
|
self.boxes.append((*box,nid,'label'))
|
|
self.label_info.append({'node':nid,'side':side,'dy':dy,'box':list(box)})
|
|
|
|
def render(self):
|
|
self.setup()
|
|
self.header()
|
|
self.background()
|
|
self.links()
|
|
leader_index=len(self.parts)
|
|
self.tracks()
|
|
self.stations()
|
|
self.labels()
|
|
self.parts[leader_index:leader_index]=self.leaders
|
|
y=self.h-80
|
|
self.path(f'M 56,{y-32} H {self.w-56}', '#aab1a7',1)
|
|
self.circle(65,y-4,6,self.paper,'#202426',2)
|
|
legend='站点 = 条目 彩色圆环 = 所属线路 胶囊 = 跨类别条目 细灰引线 = 文字对应'
|
|
if self.d.get('links'):
|
|
legend+=' 细灰箭头 = 显式关联'
|
|
self.text(81,y,legend,13,'#616b64')
|
|
default_footer='分类排布 · 仅圆环表示线路归属' if self.mode=='metro' else '时间位置按指定刻度映射 · 箭头表示资料中的关联'
|
|
self.text(56,y+26,self.d.get('footer',default_footer),12,'#7d857c')
|
|
self.text(self.w-56,y+26,'METRO' if self.mode=='metro' else 'TIMELINE',12,'#7d857c','end',600,letter_spacing=2)
|
|
desc = '时间位置按 time_scale 分段线性映射。' if self.mode=='timeline' else '站点顺序是分类排布,不表示时间、距离或推荐排名。'
|
|
svg=f'''<svg xmlns="http://www.w3.org/2000/svg" width="{self.w:g}" height="{self.h:g}" viewBox="0 0 {self.w:g} {self.h:g}" role="img" aria-labelledby="map-title map-desc">
|
|
<title id="map-title">{escape(self.d.get('title','Knowledge Map'))}</title><desc id="map-desc">{escape(desc)}</desc>
|
|
<defs><marker id="arrow" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7" fill="none" stroke="#8b9691"/></marker></defs>
|
|
<rect width="100%" height="100%" fill="{self.paper}"/>
|
|
<g font-family="{FONT}">{''.join(self.parts)}</g></svg>'''
|
|
report={'mode':self.mode,'width':self.w,'height':self.h,'routes':len(self.r),'nodes':len(self.n),'warnings':self.warnings,'labels':self.label_info,'positions':self.positions,'measurement':'Unicode-aware conservative estimate; verify actual font rendering visually.'}
|
|
return svg,report
|
|
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('input',type=Path)
|
|
parser.add_argument('--output','-o',type=Path,required=True)
|
|
parser.add_argument('--strict',action='store_true',help='fail when labels need review (overpasses alone are informational)')
|
|
args=parser.parse_args()
|
|
try:
|
|
data=json.loads(args.input.read_text(encoding='utf-8'))
|
|
svg,report=Renderer(data).render()
|
|
args.output.parent.mkdir(parents=True,exist_ok=True)
|
|
args.output.write_text(svg,encoding='utf-8')
|
|
report_path=args.output.with_suffix('.report.json')
|
|
report_path.write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
|
|
problems=[w for w in report['warnings'] if w['kind']!='overpass']
|
|
print(f'{args.output}: {report["nodes"]} nodes, {report["routes"]} routes, {len(problems)} issues to review')
|
|
if args.strict and problems:
|
|
return 2
|
|
except (ValueError,KeyError,TypeError,OSError) as exc:
|
|
print(f'Error: {exc}',file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__=='__main__':
|
|
sys.exit(main())
|