|
|
|
@@ -2,8 +2,9 @@
|
|
|
|
|
(() => {
|
|
|
|
|
'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 ? '—' : unit === '/km' ? pace(value) : new Intl.NumberFormat(undefined, {maximumFractionDigits:2}).format(value);
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
@@ -28,8 +29,8 @@
|
|
|
|
|
function render(container, data, onBucket, {compact=false, scatterOnly=false}={}) {
|
|
|
|
|
const instances = [], cleanups = [];
|
|
|
|
|
container.replaceChildren();
|
|
|
|
|
const chart = (canvas, config) => {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=unit) => ({title:{display:true,text:title,color:'#aab5b7'},reverse,ticks:{color:'#aab5b7',maxTicksLimit:6,callback:v=>number(v,unit)},grid:{color:'#354044'}});
|
|
|
|
|
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');
|
|
|
|
@@ -64,13 +65,16 @@
|
|
|
|
|
else {
|
|
|
|
|
const groups=data.display==='overlay'?[data.series]:data.series.map(s=>[s]);
|
|
|
|
|
groups.forEach(seriesGroup=>{
|
|
|
|
|
const canvas=frame(seriesGroup.map(s=>`${s.label} (${s.unit})`).join(' / '));const datasets=[],scales={...base.scales};const units=[...new Set(seriesGroup.map(s=>s.unit))];
|
|
|
|
|
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(()=>data.reference.value),borderColor:'#d2ace8',borderDash:[3,3],pointRadius:0,yAxisID:axis,type:'line',baseline:true});
|
|
|
|
|
datasets.push({label:series.label,data:series.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(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]);}}});
|
|
|
|
@@ -82,7 +86,7 @@
|
|
|
|
|
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)location.assign(`/activities/${c.data.datasets[points[0].datasetIndex].data[points[0].index].id}`);}}});
|
|
|
|
|
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());};
|
|
|
|
|