export const trafficChartRendererSource = " function setupChartCanvas(canvas){\n const rect=canvas.getBoundingClientRect();\n const dpr=window.devicePixelRatio||1;\n const cssW=Math.max(320, Math.floor(rect.width || canvas.parentElement?.clientWidth || 900));\n const cssH=Math.max(340, Math.floor(rect.height || 420));\n const pxW=Math.floor(cssW*dpr), pxH=Math.floor(cssH*dpr);\n if(canvas.width!==pxW || canvas.height!==pxH){\n canvas.width=pxW;\n canvas.height=pxH;\n }\n const ctx=canvas.getContext('2d');\n ctx.setTransform(dpr,0,0,dpr,0,0);\n return {ctx,w:cssW,h:cssH,dpr};\n }\n function fmtBytes(v){\n v=Number(v||0);\n const u=['B','KiB','MiB','GiB','TiB'];\n let i=0;\n while(v>=1024&&i({...r}));\n const step=Math.ceil(rows.length/limit);\n const output=[];\n for(let i=0;i{\n acc.downloaded+=Number(r.downloaded||0);\n acc.uploaded+=Number(r.uploaded||0);\n acc.downRate+=Number(r.avg_down_rate||0);\n acc.upRate+=Number(r.avg_up_rate||0);\n return acc;\n },{downloaded:0,uploaded:0,downRate:0,upRate:0});\n output.push({\n bucket: chunk[0]?.bucket || '',\n bucket_end: chunk[chunk.length-1]?.bucket || chunk[0]?.bucket || '',\n downloaded: sums.downloaded,\n uploaded: sums.uploaded,\n avg_down_rate: sums.downRate/chunk.length,\n avg_up_rate: sums.upRate/chunk.length,\n });\n }\n return output;\n }\n function cssColor(name, fallback){\n const value=getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n return value || fallback;\n }\n function chartTheme(){\n const body=getComputedStyle(document.body);\n return {\n body: body.color || '#d7d7d7',\n muted: cssColor('--bs-secondary-color', 'rgba(160,160,160,.85)'),\n grid: 'rgba(var(--bs-border-color-rgb, 128,128,128), .45)',\n panel: cssColor('--bs-body-bg', '#202020'),\n surface: cssColor('--bs-secondary-bg', '#2b2b2b'),\n border: cssColor('--bs-border-color', 'rgba(128,128,128,.6)'),\n down: '#2f63c7',\n up: '#209638',\n downFill: 'rgba(47,99,199,.36)',\n upFill: 'rgba(32,150,56,.32)',\n };\n }\n function drawChartPanel(ctx,w,h,theme){\n ctx.clearRect(0,0,w,h);\n ctx.fillStyle=theme.surface;\n ctx.fillRect(0,0,w,h);\n ctx.strokeStyle=theme.border;\n ctx.lineWidth=1;\n ctx.strokeRect(.5,.5,w-1,h-1);\n }\n function chartLayout(w,h){ return {left:72,right:22,top:34,bottom:42,width:w-94,height:h-76}; }\n function drawGrid(ctx,layout,maxValue,theme,suffix=''){\n ctx.strokeStyle=theme.grid;\n ctx.fillStyle=theme.muted;\n ctx.font='11px system-ui';\n ctx.lineWidth=1;\n for(let i=0;i<=4;i++){\n const y=layout.top+(layout.height*i/4);\n const value=maxValue*(1-i/4);\n ctx.beginPath();\n ctx.moveTo(layout.left,y);\n ctx.lineTo(layout.left+layout.width,y);\n ctx.stroke();\n ctx.fillText(`${fmtBytes(value)}${suffix}`,8,y+4);\n }\n }\n function drawLegend(ctx,title,theme,labels){\n ctx.fillStyle=theme.body;\n ctx.font='600 12px system-ui';\n ctx.fillText(title,14,21);\n ctx.font='11px system-ui';\n const width=ctx.canvas.width/(window.devicePixelRatio||1);\n const x=Math.max(120,width-154);\n ctx.fillStyle=theme.down; ctx.fillRect(x,10,12,10);\n ctx.fillStyle=theme.body; ctx.fillText(labels.down,x+18,19);\n ctx.fillStyle=theme.up; ctx.fillRect(x,28,12,10);\n ctx.fillStyle=theme.body; ctx.fillText(labels.up,x+18,37);\n }\n function pickXAxisIndexes(rows, maxTicks=9){\n if(rows.length<=1) return rows.length?[0]:[];\n const count=Math.min(maxTicks, rows.length);\n const seen=new Set();\n for(let i=0;ia-b);\n }\n function drawXAxis(ctx,layout,rows,theme,range){\n if(!rows.length) return;\n const y=layout.top+layout.height+20;\n ctx.fillStyle=theme.muted;\n ctx.font='11px system-ui';\n ctx.strokeStyle=theme.grid;\n pickXAxisIndexes(rows).forEach((idx)=>{\n const x=layout.left+idx*(layout.width/Math.max(1,rows.length-1));\n const label=formatBucketLabel(rows[idx].bucket, range);\n const width=ctx.measureText(label).width;\n ctx.beginPath();\n ctx.moveTo(x,layout.top+layout.height);\n ctx.lineTo(x,layout.top+layout.height+4);\n ctx.stroke();\n ctx.fillText(label,Math.max(2,Math.min(x-width/2,layout.left+layout.width-width)),y);\n });\n }\n function drawRuTorrentLine(ctx,rows,layout,maxValue,key,color,fillColor){\n const points=rows.map((r,i)=>({\n x: layout.left+i*(layout.width/Math.max(1,rows.length-1)),\n y: layout.top+layout.height-(Number(r[key]||0)/maxValue)*layout.height,\n }));\n if(!points.length) return;\n ctx.save();\n ctx.beginPath();\n points.forEach((p,i)=>{ i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y); });\n ctx.lineTo(points[points.length-1].x, layout.top+layout.height);\n ctx.lineTo(points[0].x, layout.top+layout.height);\n ctx.closePath();\n ctx.fillStyle=fillColor||color;\n ctx.fill();\n ctx.beginPath();\n points.forEach((p,i)=>{ i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y); });\n ctx.lineWidth=2.4;\n ctx.lineJoin='round';\n ctx.lineCap='round';\n ctx.strokeStyle=color;\n ctx.stroke();\n ctx.restore();\n }\n function drawHoverMarker(ctx,rows,layout,idx,theme){\n if(idx<0||idx>=rows.length) return;\n const x=layout.left+idx*(layout.width/Math.max(1,rows.length-1));\n ctx.save();\n ctx.strokeStyle=theme.body;\n ctx.globalAlpha=.55;\n ctx.beginPath();\n ctx.moveTo(x,layout.top);\n ctx.lineTo(x,layout.top+layout.height);\n ctx.stroke();\n ctx.restore();\n }\n function drawEmptyChart(canvas,message){\n const {ctx,w,h}=setupChartCanvas(canvas);\n const theme=chartTheme();\n drawChartPanel(ctx,w,h,theme);\n ctx.fillStyle=theme.muted;\n ctx.font='13px system-ui';\n ctx.fillText(message,18,34);\n }\n function tooltipNode(){\n let node=document.querySelector('.traffic-chart-tooltip');\n if(node) return node;\n node=document.createElement('div');\n node.className='traffic-chart-tooltip d-none';\n document.body.appendChild(node);\n return node;\n }\n function hideTrafficTooltip(){ tooltipNode().classList.add('d-none'); }\n function showTrafficTooltip(canvas,event,row,kind,range){\n const node=tooltipNode();\n const title=tooltipBucketLabel(row.bucket, range);\n const end=row.bucket_end && row.bucket_end!==row.bucket ? ` - ${tooltipBucketLabel(row.bucket_end, range)}` : '';\n const body=kind==='speed'\n ? `
Download: ${fmtBytes(row.avg_down_rate)}/s
Upload: ${fmtBytes(row.avg_up_rate)}/s
`\n : `
Downloaded: ${fmtBytes(row.downloaded)}
Uploaded: ${fmtBytes(row.uploaded)}
`;\n node.innerHTML=`
${esc(title+end)}
${body}`;\n node.classList.remove('d-none');\n const box=node.getBoundingClientRect();\n let left=event.clientX+14;\n let top=event.clientY+14;\n if(left+box.width>window.innerWidth-8) left=event.clientX-box.width-14;\n if(top+box.height>window.innerHeight-8) top=event.clientY-box.height-14;\n node.style.left=`${Math.max(8,left)}px`;\n node.style.top=`${Math.max(8,top)}px`;\n }\n function attachTrafficTooltip(canvas,rows,layout,kind,range){\n canvas._trafficTooltip={rows,layout,kind,range};\n if(canvas._trafficTooltipReady) return;\n canvas._trafficTooltipReady=true;\n canvas.addEventListener('mousemove',event=>{\n const data=canvas._trafficTooltip;\n if(!data||!data.rows.length) return;\n const rect=canvas.getBoundingClientRect();\n const x=event.clientX-rect.left;\n const inside=x>=data.layout.left && x<=data.layout.left+data.layout.width;\n if(!inside){ hideTrafficTooltip(); return; }\n const idx=Math.max(0,Math.min(data.rows.length-1,Math.round((x-data.layout.left)/data.layout.width*(data.rows.length-1))));\n canvas._trafficHoverIndex=idx;\n drawTrafficHistory(lastTrafficHistory);\n showTrafficTooltip(canvas,event,data.rows[idx],data.kind,data.range);\n });\n canvas.addEventListener('mouseleave',()=>{ canvas._trafficHoverIndex=-1; hideTrafficTooltip(); if(lastTrafficHistory) drawTrafficHistory(lastTrafficHistory); });\n }\n function drawTrafficHistory(hist){\n const sourceRows=Array.isArray(hist.rows)?hist.rows:[];\n const rows=downsampleRows(sourceRows);\n const range=hist.range||lastTrafficHistoryRange||'7d';\n const volume=$('trafficHistoryChart'), speed=$('trafficSpeedChart');\n if(!volume||!speed) return;\n if(!sourceRows.length){\n drawEmptyChart(volume,'No history yet. Samples appear after pyTorrent records traffic.');\n drawEmptyChart(speed,'No speed samples yet.');\n return;\n }\n const theme=chartTheme();\n\n let canvas=setupChartCanvas(volume);\n let {ctx,w,h}=canvas;\n let layout=chartLayout(w,h);\n let maxVol=Math.max(1,...rows.map(r=>Math.max(Number(r.downloaded||0),Number(r.uploaded||0))));\n drawChartPanel(ctx,w,h,theme);\n drawGrid(ctx,layout,maxVol,theme,'');\n drawRuTorrentLine(ctx,rows,layout,maxVol,'downloaded',theme.down,theme.downFill);\n drawRuTorrentLine(ctx,rows,layout,maxVol,'uploaded',theme.up,theme.upFill);\n drawHoverMarker(ctx,rows,layout,volume._trafficHoverIndex ?? -1,theme);\n drawLegend(ctx,'Transferred data',theme,{down:'Downloaded',up:'Uploaded'});\n drawXAxis(ctx,layout,rows,theme,range);\n attachTrafficTooltip(volume,rows,layout,'transfer',range);\n\n canvas=setupChartCanvas(speed);\n ({ctx,w,h}=canvas);\n layout=chartLayout(w,h);\n const maxSpeed=Math.max(1,...rows.map(r=>Math.max(Number(r.avg_down_rate||0),Number(r.avg_up_rate||0))));\n drawChartPanel(ctx,w,h,theme);\n drawGrid(ctx,layout,maxSpeed,theme,'/s');\n drawRuTorrentLine(ctx,rows,layout,maxSpeed,'avg_down_rate',theme.down,theme.downFill);\n drawRuTorrentLine(ctx,rows,layout,maxSpeed,'avg_up_rate',theme.up,theme.upFill);\n drawHoverMarker(ctx,rows,layout,speed._trafficHoverIndex ?? -1,theme);\n drawLegend(ctx,'Speed trend',theme,{down:'Download',up:'Upload'});\n drawXAxis(ctx,layout,rows,theme,range);\n attachTrafficTooltip(speed,rows,layout,'speed',range);\n }\n $('trafficModal')?.addEventListener(\"show.bs.modal\",()=>loadTrafficHistory(lastTrafficHistoryRange||\"7d\"));\n document.querySelectorAll('.traffic-history-tab').forEach(tab=>tab.addEventListener('shown.bs.tab',()=>{ if(lastTrafficHistory) requestAnimationFrame(()=>drawTrafficHistory(lastTrafficHistory)); }));\n document.querySelectorAll(\".traffic-range\").forEach(b=>b.addEventListener(\"click\",()=>{\n document.querySelectorAll(\".traffic-range\").forEach(x=>{x.classList.remove(\"btn-primary\");x.classList.add(\"btn-outline-secondary\");});\n b.classList.add(\"btn-primary\"); b.classList.remove(\"btn-outline-secondary\");\n loadTrafficHistory(b.dataset.range||\"7d\");\n }));\n window.addEventListener('resize',()=>{ if(document.body.classList.contains('modal-open') && lastTrafficHistory) requestAnimationFrame(()=>drawTrafficHistory(lastTrafficHistory)); });\n";