Runnable Notes — Commute Speed/Bearing Plot

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%).

Derived series (Python) — filter, speed, bearing, colour

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)

Station match (Python) — nearest-approach filter

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

Station labels (Chart.js plugin, JS) — vertical, de-overlap, leader lines

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();
  }
};
created 2026-07-22  ·  tags location, owntracks, gps, visualisation, runnable  ·  updated 2026-07-22  ·  version 1