/** * Chart 1 — Главный график отчёта: * - Верхняя панель: линия закупочной цены (факт) + себестоимость + 3 сценария * - Нижняя панель: барчарт маржинальности по периодам (история + прогноз) * * SVG → PNG via sharp. * * Параметры: * priceData: [{ date: 'YYYY-MM-DD', price: number }] — фактическая цена * costData: [{ date: 'YYYY-MM-DD', cost: number }] — себестоимость * scenarios: { base: [...], opt: [...], pess: [...] } — прогноз 3 сценария * marginBars: [{ label: string, value: number, isForecast: bool }] * regionLabel: string — подпись над графиком */ const sharp = require('sharp') const { PALETTE } = require('../data/palette') const { red: RED, redLt: RED_LT, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, green: GREEN, orange: ORANGE } = PALETTE function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') } async function chart1_priceAndMargin ({ priceData, costData, scenarios, marginBars, regionLabel, width = 920 }) { const W = width const topH = 360, botH = 130 const H = topH + botH + 50 const PL = 56, PR = 24 const cW = W - PL - PR const topPT = 50, topPB = 30 const topCH = topH - topPT - topPB const botPT = 6, botPB = 30 const botCH = botH - botPT - botPB // Domain const allDates = [...priceData, ...costData].map(d => d.date) const minD = new Date(allDates.reduce((a,b) => a < b ? a : b)) const maxD = new Date('2026-12-31') const xD = d => (new Date(d) - minD) / (maxD - minD) * cW const allP = [ ...priceData.map(d=>d.price), ...costData.map(d=>d.cost), ...(scenarios.base||[]).map(d=>d.price), ...(scenarios.opt||[]).map(d=>d.price), ...(scenarios.pess||[]).map(d=>d.price), ] const minP = Math.floor(Math.min(...allP)/2)*2 const maxP = Math.ceil(Math.max(...allP)/2)*2 const yP = v => topPT + topCH - (v-minP)/(maxP-minP)*topCH // Forecast cutoff const lastActual = priceData[priceData.length-1].date const fcastX = PL + xD(lastActual) let svg = '' // Forecast zone bg svg += `` // Y grid for (let v = minP; v <= maxP; v += 2) { const y = yP(v) svg += `` svg += `${v}` } svg += `руб./кг` // X year labels const years = [] const dStart = new Date(minD), dEnd = new Date(maxD) for (let y = dStart.getFullYear(); y <= dEnd.getFullYear(); y++) { [`${y}-01-01`, `${y}-07-01`].forEach(d => { if (new Date(d) >= dStart && new Date(d) <= dEnd) { const x = PL + xD(d) if (x < fcastX) svg += `${y}` } }) } // Q2*/Q3*/Q4* forecast labels ;['2026-05-15','2026-08-15','2026-11-15'].forEach((d, i) => { const x = PL + xD(d) svg += `Q${i+2}*` }) // Top X axis line svg += `` // Forecast vertical separator svg += `` svg += `ПРОГНОЗ →` // Scenario fill area (opt vs pess) if (scenarios.opt && scenarios.pess) { const optPts = scenarios.opt.map(d => `${(PL+xD(d.date)).toFixed(1)},${yP(d.price).toFixed(1)}`) const pessPts = scenarios.pess.map(d => `${(PL+xD(d.date)).toFixed(1)},${yP(d.price).toFixed(1)}`).reverse() svg += `` } // Cost line (dashed, dark) with square markers const costPath = costData.map((d,i) => `${i===0?'M':'L'}${(PL+xD(d.date)).toFixed(1)},${yP(d.cost).toFixed(1)}`).join(' ') svg += `` costData.forEach(d => { const x = PL+xD(d.date), y = yP(d.cost) svg += `` }) // Price line (red solid with circle markers) const pricePath = priceData.map((d,i) => `${i===0?'M':'L'}${(PL+xD(d.date)).toFixed(1)},${yP(d.price).toFixed(1)}`).join(' ') svg += `` priceData.forEach(d => { const x = PL+xD(d.date), y = yP(d.price) svg += `` }) // Scenario lines const drawScenario = (data, color, dash) => { if (!data || !data.length) return const path = data.map((d,i) => `${i===0?'M':'L'}${(PL+xD(d.date)).toFixed(1)},${yP(d.price).toFixed(1)}`).join(' ') svg += `` data.forEach(d => { const x = PL+xD(d.date), y = yP(d.price) svg += `` }) } drawScenario(scenarios.base, RED, '6,3') drawScenario(scenarios.opt, GREEN, '2,3') drawScenario(scenarios.pess, ORANGE, '2,3') // Legend (top-left box) const lgX = PL+10, lgY = topPT+10 const lgW = 175, lgH = 76 svg += `` const legendItems = [ { c: RED, dash: '', txt: 'Цена (факт)', marker: 'circle' }, { c: DARK, dash: '5,3', txt: 'Себест. (факт)', marker: 'square' }, { c: RED, dash: '6,3', txt: 'Базовый', marker: 'circle' }, { c: GREEN, dash: '2,3', txt: 'Оптимистичный', marker: 'circle' }, { c: ORANGE, dash: '2,3', txt: 'Пессимистичный', marker: 'circle' }, ] legendItems.forEach((it, i) => { const ly = lgY + 12 + i*13 svg += `` if (it.marker === 'circle') svg += `` else svg += `` svg += `${esc(it.txt)}` }) // ═══ Bottom panel: margin bars ═══ const bY = topH + 20 const maxM = Math.max(...marginBars.map(b=>b.value), 0) + 1 const minM = Math.min(...marginBars.map(b=>b.value), 0) const yM = v => bY + botPT + botCH - (v-minM)/(maxM-minM)*botCH const zeroY = yM(0) ;[0, 5, 10, 15].filter(v => v >= minM && v <= maxM).forEach(v => { const y = yM(v) svg += `` svg += `${v}` }) svg += `Маржа, %` // bottom forecast zone svg += `` // bars const barCount = marginBars.length const barAreaStart = PL + 8 const barAreaEnd = PL + cW - 8 const slotW = (barAreaEnd - barAreaStart) / barCount const barW = Math.min(28, slotW * 0.7) marginBars.forEach((b, i) => { const cx = barAreaStart + i*slotW + slotW/2 const x = cx - barW/2 const bH = Math.abs(yM(b.value) - zeroY) const y = b.value >= 0 ? yM(b.value) : zeroY const opacity = b.isForecast ? 0.55 : 0.85 svg += `` }) svg += `` // Source label const fullSvg = ` DairyTrends · dairy-news.ru/dairytrends ${esc(regionLabel)} ${svg} ` return { buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(), width: W, height: H + 40 } } module.exports = { chart1_priceAndMargin }