/**
* 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 = ``
return {
buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(),
width: W,
height: H + 40
}
}
module.exports = { chart1_priceAndMargin }