diff --git a/src/charts/chart2.js b/src/charts/chart2.js
index a267731..82cd219 100644
--- a/src/charts/chart2.js
+++ b/src/charts/chart2.js
@@ -1,76 +1,164 @@
/**
- * Chart 2 — Горизонтальные бары лидеров.
- * Все бары красные. Значение справа от бара жирным тем же цветом.
- * Под заголовком — подзаголовок (unit).
+ * Chart 2 — ДВЕ панели рядом:
+ * Левая: горизонтальные бары производителей по чистой прибыли (красные=переработчик, серые=производитель)
+ * Правая: вертикальные бары объёма переработки (красные)
+ *
+ * Если передана только одна группа items — рисуем одну панель.
+ *
+ * Параметры:
+ * items: [{ name, value, category?: 'producer'|'processor' }]
+ * volumeItems?: [{ name, value }] — правая панель (объём переработки)
+ * title: string
+ * unit: string
+ * volumeTitle?: string
+ * volumeUnit?: string
+ * regionLabel: string
*/
const sharp = require('sharp')
const { PALETTE } = require('../data/palette')
const { red: RED, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID } = PALETTE
+const GRAY = '#A0A0A0'
+
function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') }
-/**
- * items: [{ name: string, value: number }]
- * title: string — заголовок панели
- * unit: string — единица измерения (в подзаголовке)
- * regionLabel: string
- */
-async function chart2_leaders ({ items, title, unit = 'млн руб.', regionLabel, width = 920 }) {
+async function chart2_leaders ({
+ items, volumeItems = null,
+ title, unit = 'млн руб.',
+ volumeTitle = 'Объём переработки', volumeUnit = 'тыс. тонн',
+ regionLabel, width = 920
+}) {
const sorted = [...items].sort((a, b) => b.value - a.value)
+ const hasVolume = volumeItems && volumeItems.length > 0
const W = width
- const ROW_H = 36 // высота одной строки
- const PL = 200, PR = 100 // отступ слева (для лейблов), справа (для цифр)
- const PT = 64, PB = 28
- const H = PT + sorted.length * ROW_H + PB
- const cW = W - PL - PR
+ const H_HEADER = 40 // шапка DT
- const maxV = Math.max(...sorted.map(i => i.value)) * 1.1
- const xV = v => (v / maxV) * cW
+ if (!hasVolume) {
+ // === ОДНА ПАНЕЛЬ — горизонтальные бары ===
+ const ROW_H = 38
+ const PL = 210, PR = 100, PT = 60, PB = 28
+ const cW = W - PL - PR
+ const H = PT + sorted.length * ROW_H + PB
- let svg = ''
+ const maxV = Math.max(...sorted.map(i => i.value)) * 1.1
+ const xV = v => (v / maxV) * cW
- // Заголовок панели
- svg += `${esc(title)}`
- svg += `${esc(unit)}`
+ let svg = ''
+ svg += `${esc(title)}`
+ svg += `${esc(unit)}`
+ svg += ``
- // Ось Y (вертикальная линия)
- svg += ``
+ sorted.forEach((item, i) => {
+ const y = PT + i * ROW_H
+ const bH = 20
+ const bY = y + (ROW_H - bH) / 2
+ const bW = xV(item.value)
+ const color = (item.category === 'processor') ? RED : GRAY
+ svg += ``
+ svg += `${esc(item.name)}`
+ svg += `${item.value.toFixed(item.value >= 100 ? 0 : 1)}`
+ })
+
+ // Нижняя ось + подпись единиц
+ svg += ``
+ svg += `${esc(unit)}`
+
+ // Легенда
+ svg += ``
+ svg += `Переработчик`
+ svg += ``
+ svg += `Производитель`
+
+ const fullSvg = wrapSvg(svg, W, H, regionLabel)
+ return { buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(), width: W, height: H + H_HEADER }
+ }
+
+ // === ДВЕ ПАНЕЛИ ===
+ const gap = 60
+ const leftW = Math.round(W * 0.56)
+ const rightW = W - leftW - gap
+
+ // Левая: горизонтальные бары
+ const ROW_H = 36
+ const LPL = 180, LPR = 60, LPT = 56, LPB = 32
+ const LcW = leftW - LPL - LPR
+ const maxLeft = Math.max(...sorted.map(i => i.value)) * 1.1
+ const xL = v => (v / maxLeft) * LcW
+
+ // Правая: вертикальные бары
+ const volSorted = [...volumeItems].sort((a, b) => b.value - a.value)
+ const RPL = 36, RPR = 10, RPT = 56, RPB = 48
+ const RcW = rightW - RPL - RPR
+ const RcH = 200
+ const maxRight = Math.max(...volSorted.map(i => i.value)) * 1.12
+ const yR = v => RPT + RcH - (v / maxRight) * RcH
+ const bWR = Math.min(52, (RcW / volSorted.length) * 0.7)
+
+ const H = Math.max(LPT + sorted.length * ROW_H + LPB, RPT + RcH + RPB + 20)
+
+ let leftSvg = ''
+ leftSvg += `${esc(title)}`
+ leftSvg += `${esc(unit)}`
+ leftSvg += ``
sorted.forEach((item, i) => {
- const y = PT + i * ROW_H
- const bH = 18
+ const y = LPT + i * ROW_H
+ const bH = 19
const bY = y + (ROW_H - bH) / 2
- const bW = xV(item.value)
-
- // Бар
- svg += ``
-
- // Лейбл слева
- svg += `${esc(item.name)}`
-
- // Значение справа от бара
- const valX = PL + bW + 8
- svg += `${item.value.toFixed(1)}`
+ const bW = xL(item.value)
+ const color = (item.category === 'processor') ? RED : GRAY
+ leftSvg += ``
+ leftSvg += `${esc(item.name)}`
+ leftSvg += `${item.value.toFixed(item.value >= 100 ? 0 : 1)}`
})
+ leftSvg += ``
+ leftSvg += `${esc(unit)}`
+ // Легенда
+ leftSvg += ``
+ leftSvg += `Переработчик`
+ leftSvg += ``
+ leftSvg += `Производитель`
- // Нижняя ось
- svg += ``
- // Подпись единиц снизу
- svg += `${esc(unit)}`
+ let rightSvg = ''
+ const rx0 = leftW + gap
+ rightSvg += `${esc(volumeTitle)}`
+ rightSvg += `${esc(volumeUnit)}`
- const fullSvg = ``
-
- return {
- buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(),
- width: W,
- height: H + 40
+ // Y grid правой
+ const maxRnd = Math.ceil(maxRight / 50) * 50
+ for (let v = 0; v <= maxRnd; v += Math.ceil(maxRnd / 4 / 20) * 20) {
+ const y = yR(v)
+ rightSvg += ``
+ rightSvg += `${v}`
}
+ rightSvg += ``
+
+ const slotWR = RcW / volSorted.length
+ volSorted.forEach((item, i) => {
+ const cx = rx0 + RPL + i * slotWR + slotWR / 2
+ const x = cx - bWR / 2
+ const bH = RPT + RcH - yR(item.value)
+ const by = yR(item.value)
+ rightSvg += ``
+ rightSvg += `${item.value.toFixed(1)}`
+ const maxLen = Math.floor(slotWR / 5)
+ const name = item.name.length > maxLen ? item.name.slice(0, maxLen) + '.' : item.name
+ rightSvg += `${esc(name)}`
+ })
+ rightSvg += `${esc(volumeUnit)}`
+
+ const fullSvg = wrapSvg(leftSvg + rightSvg, W, H, regionLabel)
+ return { buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(), width: W, height: H + H_HEADER }
+}
+
+function wrapSvg (inner, W, H, regionLabel) {
+ return ``
}
module.exports = { chart2_leaders }
diff --git a/src/charts/chart3.js b/src/charts/chart3.js
index 8e9cbd0..2d92314 100644
--- a/src/charts/chart3.js
+++ b/src/charts/chart3.js
@@ -1,78 +1,97 @@
/**
- * Chart 3 — EBITDA-маржа по предприятиям.
+ * Chart 3 — EBITDA margin горизонтальные бары по предприятиям.
*
- * Вертикальные бары: все ЗЕЛЁНЫЕ (как в оригинале).
- * Над каждым баром — значение "+14.8%" жирным зелёным.
- * Под каждым баром — буква группы (А/Б/В) крупно, затем название предприятия мелко.
+ * Бары горизонтальные, цвет по группе:
+ * А (лидеры) — красный (RED)
+ * Б (стабильные) — серый
+ * В (риск/убытки) — красный с меньшей яркостью
+ *
+ * Отрицательные значения — бар уходит влево от нуля.
+ * Легенда справа.
*
* Параметры:
- * items: [{ label: string, value: number, group: 'А'|'Б'|'В' }]
+ * items: [{ label, value, group: 'А'|'Б'|'В' }]
* regionLabel: string
*/
const sharp = require('sharp')
const { PALETTE } = require('../data/palette')
-const { dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, green: GREEN } = PALETTE
+const { red: RED, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID } = PALETTE
+
+const GROUP_COLOR = { 'А': RED, 'Б': '#909090', 'В': RED }
+const GROUP_OPACITY = { 'А': 1, 'Б': 1, 'В': 0.55 }
+const LEGEND = [
+ { group: 'А', label: 'Группа А · лидеры', color: RED, opacity: 1 },
+ { group: 'Б', label: 'Группа Б · стабильные', color: '#909090', opacity: 1 },
+ { group: 'В', label: 'Группа В · убытки', color: RED, opacity: 0.55 },
+]
function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') }
-async function chart3_ebitdaByOrg ({ items, regionLabel, unit = 'EBITDA-маржа, %', width = 920 }) {
- // Сортируем по возрастанию (от минимального к максимальному — как в оригинале)
+async function chart3_ebitdaByOrg ({ items, regionLabel, unit = 'EBITDA margin, %', width = 920 }) {
+ // Сортируем: отрицательные снизу, затем по возрастанию
const sorted = [...items].sort((a, b) => a.value - b.value)
+ const ROW_H = 30
+ const PL = 160 // место для лейблов слева
+ const PR = 200 // место для легенды справа
+ const PT = 24
+ const PB = 28
+ const H = PT + sorted.length * ROW_H + PB
const W = width
- const PL = 40, PR = 20, PT = 50, PB = 80 // PB большой — место под метки
- const cW = W - PL - PR, cH = 240
+ const cW = W - PL - PR
- const H = PT + cH + PB
+ const allVals = sorted.map(i => i.value)
+ const minV = Math.min(...allVals, -6)
+ const maxV = Math.max(...allVals, 20) + 2
- const maxV = Math.max(...sorted.map(i => i.value), 25) + 2
- const minV = Math.min(...sorted.map(i => i.value), 0)
- const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH
- const zeroY = yV(0)
-
- const n = sorted.length
- const slotW = cW / n
- const barW = Math.min(48, slotW * 0.65)
+ const zeroX = PL + (-minV / (maxV - minV)) * cW
+ const xV = v => PL + ((v - minV) / (maxV - minV)) * cW
let svg = ''
- // Y grid
- const step = maxV <= 25 ? 5 : 10
+ // Вертикальные grid-линии
+ const step = (maxV - minV) > 20 ? 5 : 5
for (let v = Math.ceil(minV / step) * step; v <= maxV; v += step) {
- const y = yV(v)
- svg += ``
- svg += `${v}`
+ const x = xV(v)
+ svg += ``
+ svg += `${v}`
}
// Zero line
- svg += ``
+ svg += ``
- // Y axis label
- svg += `${esc(unit)}`
+ // X axis label
+ svg += `${esc(unit)}`
+ // Bars
+ const bH = 16
sorted.forEach((item, i) => {
- const cx = PL + i * slotW + slotW / 2
- const x = cx - barW / 2
- const bH = Math.abs(yV(item.value) - zeroY)
- const by = item.value >= 0 ? yV(item.value) : zeroY
+ const y = PT + i * ROW_H + (ROW_H - bH) / 2
+ const color = GROUP_COLOR[item.group] || MDGRAY
+ const opacity = GROUP_OPACITY[item.group] ?? 1
- // Бар (всегда зелёный как в оригинале)
- svg += ``
+ const x0 = item.value >= 0 ? zeroX : xV(item.value)
+ const barW = Math.abs(xV(item.value) - zeroX)
- // Значение над баром
- const lblY = item.value >= 0 ? by - 6 : by + bH + 14
- svg += `${item.value > 0 ? '+' : ''}${item.value.toFixed(1)}%`
+ svg += ``
- // Под баром: буква группы крупно
- const baseY = PT + cH + 16
- svg += `${esc(item.group)}`
+ // Лейбл слева
+ svg += `${esc(item.name)}`
- // Название предприятия (мелко, повёрнуто или обрезано)
- const nameY = baseY + 16
- const maxLen = Math.floor(slotW / 5.5)
- const name = item.label.length > maxLen ? item.label.slice(0, maxLen - 1) + '…' : item.label
- svg += `${esc(name)}`
+ // Значение у конца бара
+ const valX = item.value >= 0 ? xV(item.value) + 5 : xV(item.value) - 5
+ const valAnchor = item.value >= 0 ? 'start' : 'end'
+ const sign = item.value > 0 ? '+' : ''
+ svg += `${sign}${item.value.toFixed(1)}%`
+ })
+
+ // Легенда
+ const lx = PL + cW + 14
+ LEGEND.forEach((leg, i) => {
+ const ly = PT + 8 + i * 22
+ svg += ``
+ svg += `${esc(leg.label)}`
})
const fullSvg = `