diff --git a/examples/russia.js b/examples/russia.js index 5fae86a..00553c1 100644 --- a/examples/russia.js +++ b/examples/russia.js @@ -145,6 +145,59 @@ async function main () { ['IV кв. 2026', '30,0', '33,5', '-11,7', '158', { text: 'Пессимистичный', color: 'C0272D' }], ] }, + + // chart2 — лидеры по цене (горизонтальные бары) + leaders: { + title: 'Лидеры по закупочной цене', + unit: 'руб./кг', + highlightName: 'РФ (среднее)', + items: [ + { name: 'Кабардино-Балкария', value: 49.0 }, + { name: 'Брянская обл.', value: 46.5 }, + { name: 'Псковская обл.', value: 44.0 }, + { name: 'Ленинградская обл.', value: 42.5 }, + { name: 'Краснодарский край', value: 40.0 }, + { name: 'РФ (среднее)', value: 34.1 }, + { name: 'Нижегородская обл.', value: 30.5 }, + { name: 'Самарская обл.', value: 29.0 }, + { name: 'Республика Татарстан', value: 28.5 }, + { name: 'Чувашия', value: 22.0 }, + ], + }, + + // chart3 — EBITDA-маржа по группам эффективности + ebitda: { + groups: [ + { group: 'A', value: 22.0, label: 'Эффективные', share: '25%' }, + { group: 'B', value: 8.5, label: 'Средние', share: '45%' }, + { group: 'C', value: -6.0, label: 'Отстающие', share: '30%' }, + ], + }, + + // chart4 — сравнение регионов (две панели) + regionComparison: { + unit: 'руб./кг', + left: { + title: 'Топ-5 по цене', + bars: [ + { label: 'Кабардино-Балкария', value: 49.0 }, + { label: 'Брянская обл.', value: 46.5 }, + { label: 'Псковская обл.', value: 44.0 }, + { label: 'Ленинградская обл.', value: 42.5 }, + { label: 'Краснодарский край', value: 40.0 }, + ], + }, + right: { + title: 'Антитоп-5 по цене', + bars: [ + { label: 'Нижегородская обл.', value: 30.5 }, + { label: 'Ивановская обл.', value: 29.8 }, + { label: 'Самарская обл.', value: 29.0 }, + { label: 'Татарстан', value: 28.5 }, + { label: 'Чувашия', value: 22.0 }, + ], + }, + }, }, text: { @@ -172,6 +225,10 @@ async function main () { retrospectiveSummary: 'В 2024 году национальный DT-индекс показал стабильный медленный рост: 30,97 → 31,93 руб./кг (+3% за год). В феврале–марте 2025 произошёл резкий скачок: за 10 дней индекс вырос с 32,2 до 38,9 руб./кг (+21%). К апрелю достиг плато 39,8 руб./кг, удерживалось до декабря 2025. В январе–мае 2026 — нисходящая коррекция до 34,1 руб./кг (–13,5% с начала года).', + regionalSummary: 'Региональный разброс закупочных цен в 2026 г. — рекордный: от 22 руб./кг (Чувашия) до 49 руб./кг (Кабардино-Балкария), спред 27 руб./кг. Высокие цены концентрируются в СКФО и западном поясе ЦФО/СЗФО, где сильна конкуренция переработчиков за сырьё. Низкие — в Поволжье с профицитом сырого молока.', + + efficiencySummary: 'Группа A (эффективные, надой >9 000 кг/гол.) сохраняет двузначную EBITDA-маржу даже при текущих ценах. Группа C (отстающие, надой <6 000 кг/гол.) уже работает в убыток и наиболее уязвима к дальнейшему снижению цены и росту ставки ЦБ.', + forecastSummary: 'В базовом сценарии большинство производителей сохранят маржу 5–10%. В оптимистичном (цена до 36–37 руб./кг) даже менее эффективные хозяйства выйдут в плюс. В пессимистичном убытки накопят 30–40% предприятий.', risks: [ diff --git a/src/charts/chart2.js b/src/charts/chart2.js new file mode 100644 index 0000000..9ec232c --- /dev/null +++ b/src/charts/chart2.js @@ -0,0 +1,112 @@ +/** + * Chart 2 — Горизонтальные бары лидеров (переработчики / производители / регионы). + * + * Универсальный ranked-barchart: сортировка по убыванию, подпись значения справа, + * опциональная подсветка субъекта отчёта и опциональные категории (легенда). + * + * Параметры: + * items: [{ name, value, category?, isHighlight? }] + * regionLabel: string + * unit: string — единица измерения (по умолчанию 'руб./кг') + * categories: { ключ: { color, label } } — опционально, для легенды/окраски + * highlightName: string — имя строки для подсветки (альтернатива isHighlight) + * sort: bool — сортировать по убыванию (по умолчанию true) + * width: number + */ + +const sharp = require('sharp') +const { PALETTE } = require('../data/palette') +const { red: RED, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, + green: GREEN, orange: ORANGE, o: O } = PALETTE + +function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') } + +async function chart2_leaders ({ + items, regionLabel, unit = 'руб./кг', + categories = null, highlightName = null, sort = true, + width = 920 +}) { + const rows = (sort ? [...items].sort((a,b) => b.value - a.value) : [...items]) + + const W = width + const rowH = 26, gap = 8 + const PT = 24, PB = 36 + const H = PT + PB + rows.length * (rowH + gap) + + // место под подписи имён слева + const PL = 200, PR = 70 + const cW = W - PL - PR + + const maxV = Math.max(...rows.map(r => r.value), 0) + const minV = Math.min(...rows.map(r => r.value), 0) + // ось от 0 (или от minV если есть отрицательные) + const axMin = Math.min(0, minV) + const axMax = Math.ceil(maxV / 5) * 5 || maxV + const xV = v => PL + (v - axMin) / (axMax - axMin) * cW + const zeroX = xV(0) + + let svg = '' + + // вертикальные грид-линии + подписи оси X + const step = (axMax - axMin) > 40 ? 10 : 5 + for (let v = Math.ceil(axMin / step) * step; v <= axMax; v += step) { + const x = xV(v) + svg += `` + svg += `${v}` + } + svg += `${esc(unit)}` + + // бары + rows.forEach((r, i) => { + const y = PT + i * (rowH + gap) + const hl = r.isHighlight || (highlightName && r.name === highlightName) + let color = RED + if (categories && r.category && categories[r.category]) color = categories[r.category].color + if (hl) color = O + + const bx = r.value >= 0 ? zeroX : xV(r.value) + const bw = Math.abs(xV(r.value) - zeroX) + + // подпись имени слева + svg += `${esc(r.name)}` + // бар + svg += `` + // значение справа от бара + const vx = r.value >= 0 ? bx + bw + 6 : bx - 6 + const anchor = r.value >= 0 ? 'start' : 'end' + svg += `${r.value.toFixed(1)}` + }) + + // нулевая/левая ось + svg += `` + + // легенда категорий (если заданы) + let legendH = 0 + if (categories) { + const keys = Object.keys(categories) + legendH = 22 + const lx = PL + keys.forEach((k, i) => { + const cx = lx + i * 170 + const ly = PT + rows.length*(rowH+gap) + 30 + svg += `` + svg += `${esc(categories[k].label)}` + }) + } + + const totalH = H + legendH + const fullSvg = ` + + DairyTrends · dairy-news.ru/dairytrends + ${esc(regionLabel)} + ${svg} + ` + + return { + buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(), + width: W, + height: totalH + 40 + } +} + +module.exports = { chart2_leaders } diff --git a/src/charts/chart3.js b/src/charts/chart3.js new file mode 100644 index 0000000..7aaa060 --- /dev/null +++ b/src/charts/chart3.js @@ -0,0 +1,86 @@ +/** + * Chart 3 — EBITDA-маржа по группам эффективности (A / B / C). + * + * Вертикальные бары; цвет по знаку (зелёный — прибыль, оранжевый — убыток). + * Под каждым баром — буква группы крупно + подпись (доля/описание). + * + * Параметры: + * groups: [{ group: 'A', value: 18.5, label?: 'Эффективные', share?: '25%' }] + * regionLabel: string + * unit: string — по умолчанию 'EBITDA-маржа, %' + * width: number + */ + +const sharp = require('sharp') +const { PALETTE } = require('../data/palette') +const { red: RED, 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 chart3_ebitdaGroups ({ + groups, regionLabel, unit = 'EBITDA-маржа, %', width = 920 +}) { + const W = width, H = 360 + const PL = 60, PR = 40, PT = 30, PB = 84 + const cW = W - PL - PR, cH = H - PT - PB + + const vals = groups.map(g => g.value) + const maxV = Math.max(...vals, 5) + 4 + const minV = Math.min(...vals, 0) - 4 + const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH + const zeroY = yV(0) + + const grpW = cW / groups.length + const bW = Math.min(80, grpW * 0.5) + + let svg = '' + + // Y-грид + const step = (maxV - minV) > 40 ? 10 : 5 + for (let v = Math.ceil(minV / step) * step; v <= maxV; v += step) { + const y = yV(v) + svg += `` + svg += `${v}` + } + svg += `${esc(unit)}` + + // нулевая линия + svg += `` + + // бары + groups.forEach((g, i) => { + const cx = PL + i*grpW + grpW/2 + const color = g.value >= 0 ? GREEN : ORANGE + const bH = Math.abs(yV(g.value) - zeroY) + const by = g.value >= 0 ? yV(g.value) : zeroY + + svg += `` + + // значение над/под баром + const lbY = g.value >= 0 ? by - 8 : by + bH + 16 + svg += `${g.value > 0 ? '+' : ''}${g.value.toFixed(1)}%` + + // буква группы крупно + svg += `${esc(g.group)}` + // подпись группы + if (g.label) svg += `${esc(g.label)}` + // доля + if (g.share) svg += `${esc(g.share)} хозяйств` + }) + + 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 = { chart3_ebitdaGroups } diff --git a/src/charts/chart4.js b/src/charts/chart4.js new file mode 100644 index 0000000..f72df38 --- /dev/null +++ b/src/charts/chart4.js @@ -0,0 +1,102 @@ +/** + * Chart 4 — Сравнение регионов: две вертикальные панели рядом. + * + * Левая панель (например «Лидеры по цене») и правая («Аутсайдеры»). + * Общая шкала Y для честного визуального сравнения. Подсветка субъекта отчёта. + * + * Параметры: + * left: { title, bars: [{ label, value, isHighlight? }] } + * right: { title, bars: [{ label, value, isHighlight? }] } + * regionLabel: string + * unit: string — по умолчанию 'руб./кг' + * leftColor / rightColor — опционально (по умолчанию зелёный / оранжевый) + * width: number + */ + +const sharp = require('sharp') +const { PALETTE } = require('../data/palette') +const { red: RED, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, + green: GREEN, orange: ORANGE, o: O } = PALETTE + +function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') } + +async function chart4_regionCompare ({ + left, right, regionLabel, unit = 'руб./кг', + leftColor = GREEN, rightColor = ORANGE, width = 920 +}) { + const W = width, H = 360 + const PT = 56, PB = 60 + const cH = H - PT - PB + const gap = 48 // зазор между панелями + const PL = 46, PR = 16 + const panelW = (W - PL - PR - gap) / 2 + + // общая шкала по обеим панелям + const allVals = [...left.bars, ...right.bars].map(b => b.value) + const maxV = Math.ceil(Math.max(...allVals, 0) / 5) * 5 + 5 + const minV = Math.min(0, Math.floor(Math.min(...allVals) / 5) * 5) + const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH + const zeroY = yV(0) + + let svg = '' + + // Y-грид по всей ширине + const step = (maxV - minV) > 40 ? 10 : 5 + for (let v = Math.ceil(minV / step) * step; v <= maxV; v += step) { + const y = yV(v) + svg += `` + svg += `${v}` + } + svg += `${esc(unit)}` + + const drawPanel = (panel, x0, baseColor) => { + // заголовок панели + svg += `${esc(panel.title)}` + + const n = panel.bars.length + const slot = panelW / n + const bW = Math.min(46, slot * 0.62) + + panel.bars.forEach((b, i) => { + const cx = x0 + i*slot + slot/2 + const hl = b.isHighlight + const color = hl ? O : baseColor + const bH = Math.abs(yV(b.value) - zeroY) + const by = b.value >= 0 ? yV(b.value) : zeroY + + svg += `` + // значение над баром + svg += `${b.value.toFixed(1)}` + // подпись региона (с переносом-наклоном для длинных) + svg += `${esc(b.label)}` + }) + + // нулевая ось панели + svg += `` + } + + const leftX = PL + const rightX = PL + panelW + gap + + drawPanel(left, leftX, leftColor) + drawPanel(right, rightX, rightColor) + + // разделитель между панелями + const sepX = leftX + panelW + gap/2 + svg += `` + + 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 = { chart4_regionCompare } diff --git a/src/generators/document.js b/src/generators/document.js index e53d9ec..a43a986 100644 --- a/src/generators/document.js +++ b/src/generators/document.js @@ -293,6 +293,34 @@ async function buildDocument ({ subject, period, data, text, meta = {}, charts = } } + // ═══ 5. РЕГИОНАЛЬНЫЙ РАЗРЕЗ ════════════════════════════════════════════════ + if (charts.chart2 || charts.chart3 || charts.chart4) { + children.push(heading1('5. Региональный разрез и эффективность'), spacer(60)) + + if (text.regionalSummary) { + children.push(body(text.regionalSummary), spacer(100)) + } + + if (charts.chart2) { + children.push(caption('Рисунок 2. Лидеры по закупочной цене молока')) + children.push(imgPara(charts.chart2)) + children.push(spacer(80)) + } + + if (charts.chart4) { + children.push(caption('Рисунок 3. Сравнение регионов: лидеры и аутсайдеры')) + children.push(imgPara(charts.chart4)) + children.push(spacer(80)) + } + + if (charts.chart3) { + children.push(caption('Рисунок 4. EBITDA-маржа по группам эффективности хозяйств')) + children.push(imgPara(charts.chart3)) + if (text.efficiencySummary) children.push(body(text.efficiencySummary)) + children.push(spacer(80)) + } + } + // ═══ 6. ПРОГНОЗ ═══════════════════════════════════════════════════════════ children.push(heading1('6. Прогноз на II–IV кварталы 2026 года'), spacer(60)) diff --git a/src/index.js b/src/index.js index 6bb684c..727589a 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,9 @@ */ const { chart1_priceAndMargin } = require('./charts/chart1') +const { chart2_leaders } = require('./charts/chart2') +const { chart3_ebitdaGroups } = require('./charts/chart3') +const { chart4_regionCompare } = require('./charts/chart4') const { chart5_scenarioMargins } = require('./charts/chart5') const { buildDocument } = require('./generators/document') const { PALETTE } = require('./data/palette') @@ -42,6 +45,34 @@ async function generateReport (config) { }) } + if (data.leaders && data.leaders.items) { + charts.chart2 = await chart2_leaders({ + items: data.leaders.items, + categories: data.leaders.categories || null, + highlightName: data.leaders.highlightName || null, + unit: data.leaders.unit || 'руб./кг', + sort: data.leaders.sort !== false, + regionLabel: `${subject.name} · ${data.leaders.title || 'Лидеры по закупочной цене'} · ${data.leaders.unit || 'руб./кг'}` + }) + } + + if (data.ebitda) { + charts.chart3 = await chart3_ebitdaGroups({ + groups: data.ebitda.groups || data.ebitda, + unit: (data.ebitda.unit) || 'EBITDA-маржа, %', + regionLabel: `${subject.name} · Эффективность по группам хозяйств · %` + }) + } + + if (data.regionComparison) { + charts.chart4 = await chart4_regionCompare({ + left: data.regionComparison.left, + right: data.regionComparison.right, + unit: data.regionComparison.unit || 'руб./кг', + regionLabel: `${subject.name} · Сравнение регионов · ${data.regionComparison.unit || 'руб./кг'}` + }) + } + // 2) Собираем DOCX return buildDocument({ subject, period, data, text, meta, charts }) } @@ -52,6 +83,9 @@ module.exports = { // Экспортируем внутренности для возможности кастомизации charts: { chart1_priceAndMargin, + chart2_leaders, + chart3_ebitdaGroups, + chart4_regionCompare, chart5_scenarioMargins, } }