96 lines
14 KiB
JavaScript
96 lines
14 KiB
JavaScript
/* One renderer for the studio and live dashboard. All labels remain plain text. */
|
||
(() => {
|
||
'use strict';
|
||
const colors = ['#f48459', '#8ccbb4', '#9cb8e8', '#d2ace8', '#e0c375', '#75cad6'];
|
||
const unitLabel = unit => unit==='time'?'min:sec':unit;
|
||
const pace = value => { const seconds = Math.round(value); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; };
|
||
const number = (value, unit) => value == null ? '—' : ['/km','time'].includes(unit) ? pace(value) : new Intl.NumberFormat(undefined, {maximumFractionDigits:2}).format(value);
|
||
function element(tag, className, text) {
|
||
const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node;
|
||
}
|
||
function insights(container, rows, onBucket) {
|
||
rows.forEach(row => {
|
||
const unitName=row.source||'runs';
|
||
const panel = element('article', 'analysis-insight');
|
||
panel.append(element('h3', '', row.label));
|
||
if (!row.supported) panel.append(element('p', '', 'Baseline bands support mean/median/percentile effort metrics. Totals and counts use their own trend charts.'));
|
||
else if (!row.usable) panel.append(element('p', '', `Insufficient baseline variation or coverage (${row.count} usable ${unitName}). At least ${Math.max(10, row.rule.minimum)} are required.`));
|
||
else {
|
||
panel.append(element('p', '', `Baseline ${number(row.baseline, row.unit)} ${row.unit} · reference range ${number(row.low, row.unit)}–${number(row.high, row.unit)} · ${row.count} ${unitName} · ${row.start} to ${row.end}`));
|
||
panel.append(element('p', 'field-help', `Range: baseline summary ± ${row.rule.sd} population SD of individual matched ${unitName}. Descriptive range, not a confidence interval. Flag: ${row.rule.direction}, ≥${row.rule.minimum} usable ${unitName} per bucket, ${row.rule.sustained} consecutive buckets.`));
|
||
panel.append(element('strong', '', row.flagged.length ? `${row.flagged.length} buckets meet the deviation rule` : 'No buckets meet the deviation rule'));
|
||
const list = element('div', 'analysis-flags');
|
||
row.flagged.forEach(flag => {const button = element('button', 'button secondary', `${flag.label} · ${number(flag.value, row.unit)} ${row.unit} · ${flag.count} ${unitName}`);button.type='button';button.onclick=()=>onBucket?.(flag.label);list.append(button);});
|
||
panel.append(list);
|
||
}
|
||
container.append(panel);
|
||
});
|
||
}
|
||
function render(container, data, onBucket, {compact=false, scatterOnly=false}={}) {
|
||
const instances = [], cleanups = [];
|
||
container.replaceChildren();
|
||
const chart = (canvas, config) => {if(data.coverage&&!data.scatter){const plugins=config.options.plugins||(config.options.plugins={});const tooltip=plugins.tooltip||(plugins.tooltip={});const callbacks=tooltip.callbacks||(tooltip.callbacks={});callbacks.afterBody=items=>{const coverage=data.coverage[items[0]?.dataIndex];return coverage?`${coverage.sessions} independent sessions · ${coverage.minutes} usable minutes`:'';};}config.plugins=[...(config.plugins||[]),{id:'strideCanvasBackground',beforeDraw(c){const ctx=c.ctx;ctx.save();ctx.globalCompositeOperation='destination-over';ctx.fillStyle='#202629';ctx.fillRect(0,0,c.width,c.height);ctx.restore();}}];const instance = new Chart(canvas, config); instances.push(instance); return instance; };
|
||
const axes = (unit, reverse=false, title=unitLabel(unit)) => ({title:{display:true,text:title,color:'#aab5b7'},reverse,ticks:{color:'#aab5b7',maxTicksLimit:6,callback:v=>number(v,unit)},grid:{color:'#354044'}});
|
||
const base = {responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#c5cecf'}}},scales:{x:{ticks:{color:'#aab5b7',maxTicksLimit:compact?4:7},grid:{display:false}}}};
|
||
function frame(title) {
|
||
const panel = element('div', 'analysis-chart');
|
||
panel.append(element('h3', '', title));
|
||
const wrap=element('div','analysis-canvas'); const canvas=element('canvas');canvas.setAttribute('role','img');canvas.setAttribute('aria-label',title);wrap.append(canvas);panel.append(wrap);
|
||
if (!compact) {const download=element('button','text-button analysis-download','Download plot');download.type='button';download.onclick=()=>{const link=document.createElement('a');link.download='stride-analysis.png';link.href=canvas.toDataURL('image/png');link.click();};panel.append(download);}
|
||
container.append(panel);return canvas;
|
||
}
|
||
function heatmap(series) {
|
||
const panel=element('div','analysis-chart');panel.append(element('h3','',`${series.label} · ${series.unit}`));
|
||
const grid=element('div','analysis-calendar');const values=series.values.filter(v=>v!=null);const max=Math.max(...values,1);
|
||
let month='';
|
||
data.dates.forEach((date,index)=>{const current=date.slice(0,7);if(current!==month){grid.append(element('div','analysis-month',current));['M','T','W','T','F','S','S'].forEach(day=>grid.append(element('span','analysis-weekday',day)));const offset=(new Date(date+'T12:00:00Z').getUTCDay()+6)%7;for(let i=0;i<offset;i++)grid.append(element('span'));month=current;}const cell=element('button','analysis-day');cell.type='button';const value=series.values[index];cell.style.background=value==null?'#252d30':`rgba(244,132,89,${.15+.85*Math.max(0,value)/max})`;cell.append(element('span','',date.slice(8)),element('small','',number(value,series.unit)));cell.setAttribute('aria-label',`${date}: ${number(value,series.unit)} ${series.unit}`);cell.onclick=()=>onBucket?.(data.labels[index]);grid.append(cell);});panel.append(grid);container.append(panel);
|
||
}
|
||
if (!scatterOnly && data.type!=='none') {
|
||
if(data.type==='heatmap') data.series.forEach(heatmap);
|
||
else if(data.type==='stacked'){
|
||
const series=data.series[0],canvas=frame(`${series.label} · by cohort (${series.unit})`);
|
||
chart(canvas,{type:'bar',data:{labels:data.labels,datasets:data.cohorts.map((cohort,i)=>({label:cohort.label,data:cohort.values,backgroundColor:colors[i%colors.length]}))},options:{...base,scales:{x:{...base.scales.x,stacked:true},y:{...axes(series.unit),stacked:true}},plugins:{...base.plugins,tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${number(c.parsed.y,series.unit)} ${series.unit}`}}},onClick:(_,points)=>{if(points.length)onBucket?.(data.labels[points[0].index]);}}});
|
||
container.append(element('p','field-help','Each input belongs to the first matching cohort; unmatched or missing inputs are shown separately.'));
|
||
}
|
||
else if(data.type==='histogram') data.series.forEach(series=>{
|
||
const h=series.histogram;const canvas=frame(`${h.field[0]} distribution · ${h.field[1]}`);
|
||
chart(canvas,{type:'bar',data:{labels:h.counts.map((_,i)=>`${number(h.edges[i],h.field[1])}–${number(h.edges[i+1],h.field[1])}`),datasets:[{label:data.source||'Runs',data:h.counts,backgroundColor:colors[0]}]},options:{...base,scales:{...base.scales,y:axes(data.source||'runs')},plugins:{...base.plugins,legend:{display:false}},onClick:(_,points)=>{if(points.length)onBucket?.(h.buckets[points[0].index]);}}});
|
||
});
|
||
else if(data.type==='box') data.series.forEach(series=>{
|
||
const canvas=frame(`${series.label} · per-run distribution`);
|
||
const boxes=series.boxes;
|
||
const whiskers={id:'strideWhiskers',afterDatasetsDraw(c){const ctx=c.ctx;const meta=c.getDatasetMeta(0);ctx.save();ctx.strokeStyle=colors[0];ctx.lineWidth=2;boxes.forEach((b,i)=>{if(b.count===0)return;const mark=meta.data[i];const x=mark.x,y=c.scales.y;ctx.beginPath();ctx.moveTo(x,y.getPixelForValue(b.low));ctx.lineTo(x,y.getPixelForValue(b.high));for(const v of [b.low,b.high,b.median]){ctx.moveTo(x-9,y.getPixelForValue(v));ctx.lineTo(x+9,y.getPixelForValue(v));}ctx.stroke();});ctx.restore();}};
|
||
chart(canvas,{type:'bar',plugins:[whiskers],data:{labels:data.labels,datasets:[{label:'25th–75th percentile',data:boxes.map(b=>b.count?[b.q1,b.q3]:null),backgroundColor:colors[0]+'66',borderColor:colors[0],borderWidth:1},{label:'Range',data:boxes.map(b=>b.low),type:'line',pointRadius:0,borderWidth:0},{label:'Range',data:boxes.map(b=>b.high),type:'line',pointRadius:0,borderWidth:0}]},options:{...base,plugins:{legend:{display:false},tooltip:{filter:c=>c.datasetIndex===0,callbacks:{label:c=>{const b=boxes[c.dataIndex];return [`Median ${number(b.median,series.unit)} ${series.unit}`,`Quartiles ${number(b.q1,series.unit)}–${number(b.q3,series.unit)}`,`Range ${number(b.low,series.unit)}–${number(b.high,series.unit)}`,`${b.count} usable runs`];}}}},scales:{...base.scales,y:axes(series.unit)},onClick:(_,points)=>{if(points.length)onBucket?.(data.labels[points[0].index]);}}});
|
||
});
|
||
else {
|
||
const groups=data.display==='overlay'?[data.series]:data.series.map(s=>[s]);
|
||
groups.forEach(seriesGroup=>{
|
||
const canvas=frame(seriesGroup.map(s=>`${s.label} (${unitLabel(s.unit)})`).join(' / '));const datasets=[],scales={...base.scales};const units=[...new Set(seriesGroup.map(s=>s.unit))];
|
||
units.forEach((unit,i)=>{scales[`y${i}`]={...axes(unit),position:i%2?'right':'left',grid:{color:'#354044',drawOnChartArea:i===0}};});
|
||
seriesGroup.forEach((series,index)=>{
|
||
const color=colors[index%colors.length], axis=`y${units.indexOf(series.unit)}`;const automatic=['bin','weekday','hour','run','all'].includes(data.group)||['distance','duration','elevation','count'].includes(series.key)?'bar':'line';const type=data.type==='auto'?automatic:data.type==='area'?'line':data.type;
|
||
const timeAxis=data.group==='run'&&data.timestamps?.length&&['line','area'].includes(data.type);
|
||
const values=timeAxis?series.values.map((y,i)=>({x:data.timestamps[i],y})):series.values;
|
||
if(timeAxis)scales.x={type:'linear',min:Math.min(...data.timestamps)-(data.timestamps.length===1?43200000:0),max:Math.max(...data.timestamps)+(data.timestamps.length===1?43200000:0),ticks:{color:'#aab5b7',maxTicksLimit:compact?4:7,callback:v=>new Intl.DateTimeFormat(undefined,{timeZone:data.timezone||'UTC',month:'short',day:'numeric'}).format(new Date(v))},grid:{display:false}};
|
||
if(series.baseline?.value!=null){const b=series.baseline;datasets.push({label:'Baseline range lower',data:data.labels.map(()=>b.low),borderWidth:0,pointRadius:0,yAxisID:axis,type:'line',baseline:true},{label:'Baseline range',data:data.labels.map(()=>b.high),borderWidth:0,pointRadius:0,fill:'-1',backgroundColor:'#8ccbb41c',yAxisID:axis,type:'line',baseline:true},{label:`${series.label} baseline`,data:data.labels.map(()=>b.value),borderColor:'#8ccbb4',borderDash:[4,4],pointRadius:0,borderWidth:1,yAxisID:axis,type:'line',baseline:true});}
|
||
if(data.reference?.field===series.key)datasets.push({label:'Reference',data:data.labels.map((_,i)=>timeAxis?{x:data.timestamps[i],y:data.reference.value}:data.reference.value),borderColor:'#d2ace8',borderDash:[3,3],pointRadius:0,yAxisID:axis,type:'line',baseline:true});
|
||
datasets.push({label:series.label,data:values,type,yAxisID:axis,borderColor:color,backgroundColor:color+(type==='bar'?'bb':'33'),borderWidth:2,pointRadius:c=>series.baseline?.flags[c.dataIndex]?6:2,pointStyle:c=>series.baseline?.flags[c.dataIndex]?'triangle':'circle',tension:.15,spanGaps:false,fill:data.type==='area',unit:series.unit});
|
||
if(series.previous.length)datasets.push({label:`${series.label} · previous`,data:series.previous,type,yAxisID:axis,borderColor:'#aab5b7',borderDash:[5,5],borderWidth:1,pointRadius:1,backgroundColor:'#aab5b722',previous:true,unit:series.unit});
|
||
});
|
||
chart(canvas,{type:'line',data:{labels:data.labels,datasets},options:{...base,scales,plugins:{legend:{labels:{color:'#c5cecf',filter:item=>!['Baseline range lower','Baseline range'].includes(item.text)}},tooltip:{filter:c=>!c.dataset.baseline,callbacks:{label:c=>`${c.dataset.label}: ${number(c.parsed.y,c.dataset.unit)} ${c.dataset.unit}`}}},onClick:(_,points,c)=>{const p=points.find(p=>!c.data.datasets[p.datasetIndex].baseline&&!c.data.datasets[p.datasetIndex].previous);if(p)onBucket?.(data.labels[p.index]);}}});
|
||
});
|
||
}
|
||
}
|
||
if(data.scatter){
|
||
const s=data.scatter,canvas=frame(`${s.x[0]} vs ${s.y[0]}${s.correlation==null?'':` · r = ${s.correlation.toFixed(3)}`}`);
|
||
const finite=values=>values.filter(v=>typeof v==='number'&&Number.isFinite(v));
|
||
const colorValues=finite(s.current.map(p=>p.color)), sizeValues=finite(s.current.map(p=>p.size));const extent=values=>values.length?[Math.min(...values),Math.max(...values)]:[0,0];const [cmin,cmax]=extent(colorValues),[smin,smax]=extent(sizeValues);
|
||
const scale=(v,min,max)=>v==null?.5:max===min?.5:Math.max(0,Math.min(1,(v-min)/(max-min)));
|
||
chart(canvas,{type:'scatter',data:{datasets:[{label:'Current',data:s.current,backgroundColor:c=>s.color?`hsl(${25+160*scale(c.raw?.color,cmin,cmax)} 65% 65%)`:colors[0],pointRadius:c=>s.size?3+6*scale(c.raw?.size,smin,smax):4},...(s.previous.length?[{label:'Previous',data:s.previous,backgroundColor:'#aab5b7',pointRadius:3}]:[])]},options:{...base,scales:{x:axes(s.x[1],false,`${s.x[0]} (${s.x[1]})`),y:axes(s.y[1],false,`${s.y[0]} (${s.y[1]})`)},plugins:{legend:{labels:{color:'#c5cecf'}},tooltip:{callbacks:{label:c=>[c.raw.label,`${number(c.raw.x,s.x[1])} ${s.x[1]} / ${number(c.raw.y,s.y[1])} ${s.y[1]}`,...(s.color?[`${s.color[0]}: ${number(c.raw.color,s.color[1])} ${s.color[1]}`]:[]),...(s.size?[`${s.size[0]}: ${number(c.raw.size,s.size[1])} ${s.size[1]}`]:[])]}}},onClick:(_,points,c)=>{if(points.length){const id=c.data.datasets[points[0].datasetIndex].data[points[0].index].id;if(id)location.assign(`/activities/${id}`);}}}});
|
||
if(s.color||s.size)container.append(element('p','field-help',`${s.color?`Color: ${s.color[0]} · ${number(cmin,s.color[1])} (warm) to ${number(cmax,s.color[1])} (cool). `:''}${s.size?`Size: ${s.size[0]} · ${number(smin,s.size[1])} to ${number(smax,s.size[1])}.`:''}`));
|
||
}
|
||
return ()=>{instances.forEach(c=>c.destroy());cleanups.forEach(f=>f());};
|
||
}
|
||
window.StrideCharts={render,insights,element,number};
|
||
})();
|