feat: chart2/3/4 + section 5 (regional breakdown)

- chart2_leaders: horizontal ranked bars with highlight & category coloring
- chart3_ebitdaGroups: vertical bars colored by sign (green ≥0, orange <0)
- chart4_regionCompare: side-by-side bar panels with shared y-scale
- document.js: new section 5 'Региональный разрез и эффективность' between
  sections 4 and 6, renders chart2/3/4 with figure captions
- index.js: render hooks for data.leaders, data.ebitda, data.regionComparison
- examples/russia.js: demo data for all three new charts
This commit is contained in:
Ник
2026-06-08 16:34:07 +03:00
parent 0255d2591d
commit a0af0ba772
6 changed files with 419 additions and 0 deletions
+57
View File
@@ -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: [
+112
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
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 += `<line x1="${x.toFixed(1)}" y1="${PT}" x2="${x.toFixed(1)}" y2="${PT + rows.length*(rowH+gap)}" stroke="${GRID}" stroke-width="1"/>`
svg += `<text x="${x.toFixed(1)}" y="${(PT + rows.length*(rowH+gap) + 16).toFixed(1)}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${v}</text>`
}
svg += `<text x="${(PL + cW).toFixed(1)}" y="${(PT + rows.length*(rowH+gap) + 30).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${esc(unit)}</text>`
// бары
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 += `<text x="${(PL - 10).toFixed(1)}" y="${(y + rowH/2 + 4).toFixed(1)}" text-anchor="end" font-size="11" font-weight="${hl ? 'bold' : 'normal'}" fill="${hl ? O : DARK}">${esc(r.name)}</text>`
// бар
svg += `<rect x="${bx.toFixed(1)}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${rowH}" fill="${color}" rx="2" opacity="${hl ? 1 : 0.88}"/>`
// значение справа от бара
const vx = r.value >= 0 ? bx + bw + 6 : bx - 6
const anchor = r.value >= 0 ? 'start' : 'end'
svg += `<text x="${vx.toFixed(1)}" y="${(y + rowH/2 + 4).toFixed(1)}" text-anchor="${anchor}" font-size="11" font-weight="bold" fill="${color}">${r.value.toFixed(1)}</text>`
})
// нулевая/левая ось
svg += `<line x1="${zeroX.toFixed(1)}" y1="${PT}" x2="${zeroX.toFixed(1)}" y2="${(PT + rows.length*(rowH+gap)).toFixed(1)}" stroke="${LTGRAY}" stroke-width="1"/>`
// легенда категорий (если заданы)
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 += `<rect x="${cx}" y="${ly}" width="14" height="12" fill="${categories[k].color}" rx="2" opacity="0.9"/>`
svg += `<text x="${cx + 20}" y="${ly + 10}" font-size="11" fill="${DARK}">${esc(categories[k].label)}</text>`
})
}
const totalH = H + legendH
const fullSvg = `<svg width="${W}" height="${totalH + 40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif">
<rect x="0" y="0" width="3" height="40" fill="${RED}"/>
<text x="14" y="18" font-size="12" font-weight="bold" fill="${RED}">DairyTrends · dairy-news.ru/dairytrends</text>
<text x="14" y="34" font-size="11" fill="${MDGRAY}">${esc(regionLabel)}</text>
<g transform="translate(0,40)">${svg}</g>
</svg>`
return {
buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(),
width: W,
height: totalH + 40
}
}
module.exports = { chart2_leaders }
+86
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
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 += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${PL+cW}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
svg += `<text x="${PL-8}" y="${(y+4).toFixed(1)}" text-anchor="end" font-size="11" fill="${MDGRAY}">${v}</text>`
}
svg += `<text x="14" y="${PT+cH/2}" text-anchor="middle" font-size="11" fill="${MDGRAY}" transform="rotate(-90,14,${PT+cH/2})">${esc(unit)}</text>`
// нулевая линия
svg += `<line x1="${PL}" y1="${zeroY.toFixed(1)}" x2="${PL+cW}" y2="${zeroY.toFixed(1)}" stroke="${DARK}" stroke-width="1"/>`
// бары
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 += `<rect x="${(cx-bW/2).toFixed(1)}" y="${by.toFixed(1)}" width="${bW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${color}" rx="3" opacity="0.9"/>`
// значение над/под баром
const lbY = g.value >= 0 ? by - 8 : by + bH + 16
svg += `<text x="${cx.toFixed(1)}" y="${lbY.toFixed(1)}" text-anchor="middle" font-size="13" font-weight="bold" fill="${color}">${g.value > 0 ? '+' : ''}${g.value.toFixed(1)}%</text>`
// буква группы крупно
svg += `<text x="${cx.toFixed(1)}" y="${(PT+cH+34).toFixed(1)}" text-anchor="middle" font-size="22" font-weight="bold" fill="${DARK}">${esc(g.group)}</text>`
// подпись группы
if (g.label) svg += `<text x="${cx.toFixed(1)}" y="${(PT+cH+52).toFixed(1)}" text-anchor="middle" font-size="11" fill="${MDGRAY}">${esc(g.label)}</text>`
// доля
if (g.share) svg += `<text x="${cx.toFixed(1)}" y="${(PT+cH+68).toFixed(1)}" text-anchor="middle" font-size="10" fill="${LTGRAY}">${esc(g.share)} хозяйств</text>`
})
const fullSvg = `<svg width="${W}" height="${H+40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif">
<rect x="0" y="0" width="3" height="40" fill="${RED}"/>
<text x="14" y="18" font-size="12" font-weight="bold" fill="${RED}">DairyTrends · dairy-news.ru/dairytrends</text>
<text x="14" y="34" font-size="11" fill="${MDGRAY}">${esc(regionLabel)}</text>
<g transform="translate(0,40)">${svg}</g>
</svg>`
return {
buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(),
width: W,
height: H + 40
}
}
module.exports = { chart3_ebitdaGroups }
+102
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
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 += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
svg += `<text x="${PL-8}" y="${(y+4).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${v}</text>`
}
svg += `<text x="13" y="${PT+cH/2}" text-anchor="middle" font-size="10" fill="${MDGRAY}" transform="rotate(-90,13,${PT+cH/2})">${esc(unit)}</text>`
const drawPanel = (panel, x0, baseColor) => {
// заголовок панели
svg += `<text x="${(x0 + panelW/2).toFixed(1)}" y="${(PT-26).toFixed(1)}" text-anchor="middle" font-size="13" font-weight="bold" fill="${DARK}">${esc(panel.title)}</text>`
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 += `<rect x="${(cx-bW/2).toFixed(1)}" y="${by.toFixed(1)}" width="${bW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${color}" rx="2" opacity="${hl ? 1 : 0.88}"/>`
// значение над баром
svg += `<text x="${cx.toFixed(1)}" y="${(by-6).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="bold" fill="${color}">${b.value.toFixed(1)}</text>`
// подпись региона (с переносом-наклоном для длинных)
svg += `<text x="${cx.toFixed(1)}" y="${(PT+cH+16).toFixed(1)}" text-anchor="end" font-size="10" fill="${hl ? O : DARK}" font-weight="${hl ? 'bold' : 'normal'}" transform="rotate(-35,${cx.toFixed(1)},${(PT+cH+16).toFixed(1)})">${esc(b.label)}</text>`
})
// нулевая ось панели
svg += `<line x1="${x0.toFixed(1)}" y1="${zeroY.toFixed(1)}" x2="${(x0+panelW).toFixed(1)}" y2="${zeroY.toFixed(1)}" stroke="${LTGRAY}" stroke-width="1"/>`
}
const leftX = PL
const rightX = PL + panelW + gap
drawPanel(left, leftX, leftColor)
drawPanel(right, rightX, rightColor)
// разделитель между панелями
const sepX = leftX + panelW + gap/2
svg += `<line x1="${sepX.toFixed(1)}" y1="${(PT-10).toFixed(1)}" x2="${sepX.toFixed(1)}" y2="${(PT+cH).toFixed(1)}" stroke="${GRID}" stroke-width="1" stroke-dasharray="3,3"/>`
const fullSvg = `<svg width="${W}" height="${H+40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif">
<rect x="0" y="0" width="3" height="40" fill="${RED}"/>
<text x="14" y="18" font-size="12" font-weight="bold" fill="${RED}">DairyTrends · dairy-news.ru/dairytrends</text>
<text x="14" y="34" font-size="11" fill="${MDGRAY}">${esc(regionLabel)}</text>
<g transform="translate(0,40)">${svg}</g>
</svg>`
return {
buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(),
width: W,
height: H + 40
}
}
module.exports = { chart4_regionCompare }
+28
View File
@@ -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))
+34
View File
@@ -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,
}
}