#!/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'{escape(str(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'')
def circle(self, x, y, radius, fill, stroke='none', sw=0):
self.parts.append(f'')
def rect(self, x, y, w, h, fill, rx=0, stroke='none', sw=0):
self.parts.append(f'')
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'{escape(link.get("label", "关联 / ASSOCIATION"))}')
self.path(bezier([(a['x'],a['y']),(b['x'],b['y'])]),'#8b9691',1.3,opacity='.6',marker_end='url(#arrow)')
self.parts.append('')
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']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'{escape(r.get("label",r["id"]))}')
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('')
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'{escape(n.get("label",nid))}')
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'')
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('')
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 yself.h-115
hits=sum(overlaps(box,b[:4],5) for b in self.boxes)
railhits=sum(box[0]-92 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'')
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''''''
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())