// Reusable SVG charts. // Smooth path through points using monotone cubic function smoothPath(points){ if(points.length<2) return ''; const path = [`M ${points[0].x} ${points[0].y}`]; for(let i=0;i [d.inventory, d.demand].filter(v=>v!=null)); const maxV = Math.ceil(Math.max(...allValues)/10000)*10000; const minV = 0; const x = i => pad.l + (i/(data.length-1))*innerW; const y = v => pad.t + innerH - ((v-minV)/(maxV-minV))*innerH; const invPts = data.map((d,i)=>({x:x(i),y:y(d.inventory)})); const demPts = data.map((d,i)=>({x:x(i),y:y(d.demand)})); const yTicks = 5; const ticks = Array.from({length:yTicks+1},(_,i)=>(maxV/yTicks)*i); const tipIdx = data.findIndex(d=>d.day===showTooltipDay); const tip = tipIdx>=0 ? data[tipIdx] : null; const tipX = tip ? x(tipIdx) : 0; const tipY = tip ? y(tip.inventory) : 0; return ( {ticks.map((t,i)=>( {t>=1000?(t/1000)+'K':t} ))} {/* Area under inventory */} {/* Demand (dashed gray) */} {/* Inventory (amber) */} {/* X labels */} {[0,7,14,21,30].map(i=>( {(i+1)} may. ))} {tip && ( {tip.day} may. 2025 Inventario: {tip.inventory.toLocaleString()} Demanda: {tip.demand.toLocaleString()} )} ); } function Donut({data, total, label, size=180}){ const r = size/2 - 14; const C = 2*Math.PI*r; const cx = size/2, cy = size/2; let acc = 0; return ( {data.map((d,i)=>{ const len = (d.pct/100)*C; const dash = `${len} ${C-len}`; const offset = -((acc/100)*C); const el = ; acc += d.pct; return el; })} {total.toLocaleString()} {label} ); } function HBar({items, suffix=''}){ const max = Math.max(...items.map(i=>Math.abs(i.value))); return (
{items.map((it,i)=>(
{it.label}
{it.value>0?'+':''}{it.value}{suffix}
))}
); } function Sparkline({data, w=80, h=24, color='#F5A524', fill=true}){ if(!data || !data.length) return null; const min = Math.min(...data), max = Math.max(...data); const range = max-min || 1; const pts = data.map((v,i)=>({ x: (i/(data.length-1))*w, y: h - ((v-min)/range)*h*0.85 - h*0.1 })); const path = smoothPath(pts); const area = `${path} L ${pts[pts.length-1].x} ${h} L ${pts[0].x} ${h} Z`; return ( {fill && } ); } function ForecastChart({data, height=300}){ const W=920, H=height; const pad={l:50,r:20,t:20,b:30}; const innerW = W-pad.l-pad.r, innerH = H-pad.t-pad.b; const all = data.flatMap(d=>[d.actual,d.forecast,d.upper,d.lower].filter(v=>v!=null)); const maxV = Math.ceil(Math.max(...all)/5000)*5000; const x = i => pad.l + (i/(data.length-1))*innerW; const y = v => pad.t + innerH - (v/maxV)*innerH; const actualPts = data.filter(d=>d.actual!=null).map((d,i)=>({x:x(data.indexOf(d)),y:y(d.actual)})); const fcPts = data.filter(d=>d.forecast!=null).map((d)=>({x:x(data.indexOf(d)),y:y(d.forecast)})); const upperPts = data.filter(d=>d.upper!=null).map((d)=>({x:x(data.indexOf(d)),y:y(d.upper)})); const lowerPts = data.filter(d=>d.lower!=null).map((d)=>({x:x(data.indexOf(d)),y:y(d.lower)})); const bandPath = `${smoothPath(upperPts)} L ${lowerPts[lowerPts.length-1].x} ${lowerPts[lowerPts.length-1].y} ${smoothPath([...lowerPts].reverse()).replace(/^M/,'L')} Z`; const splitX = x(60); const ticks = [0,1,2,3,4].map(i=>maxV/4*i); return ( {ticks.map((t,i)=>( {(t/1000).toFixed(0)}K ))} {/* Forecast region */} PRONÓSTICO IA → {/* Confidence band */} {/* Actual line */} {/* Forecast line */} {/* X labels */} {[0,15,30,45,60,75,89].map(i=>{ const day = i+1; const month = i<31?'mar':i<61?'abr':'may'; const dom = i<31?day:i<61?day-31:day-61; return {dom} {month}; })} ); } // Heatmap by store x category function Heatmap({rows, cols, values, format=(v)=>v}){ const max = Math.max(...values.flat()); const min = Math.min(...values.flat()); const colorFor = v => { const t = (v-min)/(max-min||1); // amber scale return `rgba(245, 165, 36, ${0.08 + t*0.85})`; }; return (
{cols.map(c=>)} {rows.map((r,i)=>( {cols.map((c,j)=>( ))} ))}
{c}
{r}0.55 ? 'var(--ink)' : 'var(--ink-2)',fontSize:12,fontWeight:600,textAlign:'center',padding:'10px 0',borderRadius:6,minWidth:64}}>{format(values[i][j])}
); } Object.assign(window, { LineChart, Donut, HBar, Sparkline, ForecastChart, Heatmap, smoothPath });