The durable pieces behind the speed/bearing + station-overlay commute plot (see using-gps-data, Visualisation convention). Data (the OwnTracks track, matched stations) is regenerated from PostGIS each time and deliberately not stored here — only the logic is. Track points are dicts with t (HH:MM:SS), lat, lon, acc.
Parameters: ACC_CAP=100 m (drop noisier fixes), MAX_KMH=400 (drop implausible residual segments), MATCH_RADIUS_M=600 (station kept if the track passes within this), hue = hsl(bearing, 72%, 55%).
import math
R = 6371000.0
rad = lambda d: d*math.pi/180
def haversine(a, b):
dLat=rad(b['lat']-a['lat']); dLon=rad(b['lon']-a['lon'])
la1=rad(a['lat']); la2=rad(b['lat'])
h=math.sin(dLat/2)**2+math.cos(la1)*math.cos(la2)*math.sin(dLon/2)**2
return 2*R*math.asin(math.sqrt(h))
def bearing(a, b):
la1=rad(a['lat']); la2=rad(b['lat']); dLon=rad(b['lon']-a['lon'])
y=math.sin(dLon)*math.cos(la2)
x=math.cos(la1)*math.sin(la2)-math.sin(la1)*math.cos(la2)*math.cos(dLon)
return (math.degrees(math.atan2(y,x))+360)%360
secs = lambda t: (lambda h,m,s: h*3600+m*60+s)(*map(int, t.split(':')))
def series(pts, ACC_CAP=100, MAX_KMH=400):
pts=[p for p in pts if p['acc']<=ACC_CAP]
labels=[]; speeds=[]; bearings=[]; colours=[]
for i in range(1, len(pts)):
a,b=pts[i-1],pts[i]; dt=secs(b['t'])-secs(a['t'])
if dt<=0: continue
kmh=(haversine(a,b)/dt)*3.6
if kmh>MAX_KMH: continue
br=bearing(a,b)
labels.append(b['t']); speeds.append(round(kmh,1)); bearings.append(round(br,1))
colours.append(f'hsl({round(br)},72%,55%)')
return dict(labels=labels, speeds=speeds, bearings=bearings, colours=colours)
Candidate stations come from location-db/gazetteer (category='station', bbox of the track). Each kept station is placed at the time of its closest approach; off-route stations self-exclude at >1 km.
def match_stations(track, stations, MATCH_RADIUS_M=600):
out=[]
for s in stations:
best=min(((haversine(s,p), p['t']) for p in track), key=lambda x: x[0])
if best[0] <= MATCH_RADIUS_M:
out.append({'name': s['name'], 'dist': round(best[0]), 't': best[1]})
out.sort(key=lambda r: secs(r['t']))
return out # then map each t to the nearest chart label to position the marker
Vertical bold-white text with a dark halo in a padded band above the plot; a leader line elbows at the bar's true x down to its top. De-overlap: left-to-right push (min slot ~13 px), right-edge clamp then leftward relax, plus left-edge clamp — so the rightmost label (e.g. a terminus on the last point) stays inside the canvas. Needs layout.padding.top ≈ 112 to fit the longest name vertically. D.stations = [{label, name, speed}] with label the nearest chart x-label.
const stationLabels = {
id:'stationLabels',
afterDraw(chart){
const {ctx, chartArea:ca, scales}=chart; const xs=scales.x, ys=scales.y;
if(!D.stations.length) return;
ctx.save();
ctx.font='bold 11px -apple-system, sans-serif';
const slotW=13, edge=7, textPad=6;
const it=D.stations.map(s=>({name:s.name, tx:xs.getPixelForValue(s.label), ty:ys.getPixelForValue(s.speed)}));
it.sort((a,b)=>a.tx-b.tx); it.forEach(o=>o.lx=o.tx);
for(let i=1;i<it.length;i++){const n=it[i-1].lx+slotW; if(it[i].lx<n) it[i].lx=n;}
const rmax=ca.right-edge;
if(it.length && it[it.length-1].lx>rmax){ it[it.length-1].lx=rmax;
for(let i=it.length-2;i>=0;i--){const cap=it[i+1].lx-slotW; if(it[i].lx>cap) it[i].lx=cap;} }
const lmin=ca.left+edge;
for(let i=0;i<it.length;i++){ if(it[i].lx<lmin){ it[i].lx=lmin;
for(let k=i+1;k<it.length;k++){const n=it[k-1].lx+slotW; if(it[k].lx<n) it[k].lx=n;} } }
const band=ca.top-4;
it.forEach(o=>{
ctx.strokeStyle='rgba(230,232,235,0.7)'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(o.lx,band); ctx.lineTo(o.tx,ca.top); ctx.lineTo(o.tx,o.ty); ctx.stroke();
ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.beginPath(); ctx.arc(o.tx,o.ty,1.8,0,Math.PI*2); ctx.fill();
});
it.forEach(o=>{
ctx.save(); ctx.translate(o.lx, band-textPad); ctx.rotate(-Math.PI/2);
ctx.textAlign='left'; ctx.textBaseline='middle'; ctx.lineJoin='round';
ctx.strokeStyle='rgba(0,0,0,0.85)'; ctx.lineWidth=3.5; ctx.strokeText(o.name,0,0);
ctx.fillStyle='#fff'; ctx.fillText(o.name,0,0); ctx.restore();
});
ctx.restore();
}
};