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 = ``
+
+ 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 = ``
+
+ 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 = ``
+
+ 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,
}
}