#!/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''' {escape(self.d.get('title','Knowledge Map'))}{escape(desc)} {''.join(self.parts)}''' 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())