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 = ` - - DairyTrends · dairy-news.ru/dairytrends - ${esc(regionLabel)} - ${svg} - ` - - 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 ` + + DairyTrends · dairy-news.ru/dairytrends + ${esc(regionLabel)} + ${inner} + ` } 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 = ` diff --git a/src/charts/chart5.js b/src/charts/chart5.js index 92f6282..5e66bc4 100644 --- a/src/charts/chart5.js +++ b/src/charts/chart5.js @@ -1,73 +1,79 @@ /** * Chart 5 — Сценарии маржинальности (вертикальные группированные бары) + * + * Цвета как в оригинале: зелёный / красный / серый + * Поле: q.label ИЛИ q.quarter (поддерживаем оба) */ 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 +const { red: RED, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, green: GREEN } = PALETTE + +const GRAY_DARK = '#606060' // пессимистичный — тёмно-серый function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') } -/** - * quarters: [{ label: 'II кв. 2026', opt: 15.7, base: 10.0, pess: 1.5 }] - */ async function chart5_scenarioMargins ({ quarters, regionLabel, width = 920 }) { const W = width, H = 360 const PL = 60, PR = 180, PT = 80, PB = 50 const cW = W - PL - PR, cH = H - PT - PB - const all = quarters.flatMap(q => [q.opt, q.base, q.pess]) + // Поддерживаем q.label и q.quarter + const items = quarters.map(q => ({ ...q, label: q.label || q.quarter || '' })) + + const all = items.flatMap(q => [q.opt, q.base, q.pess]) const maxV = Math.max(...all, 5) + 3 const minV = Math.min(...all, 0) - 3 - const yV = v => PT + cH - (v-minV)/(maxV-minV)*cH + const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH const zeroY = yV(0) - const grpW = cW / quarters.length - const bW = Math.min(40, grpW / 4) + const grpW = cW / items.length + const bW = Math.min(38, grpW / 4) let svg = '' - // Y grid - for (let v = Math.ceil(minV/5)*5; v <= maxV; v += 5) { - const y = yV(v) - svg += `` - svg += `${v}` - } - // Zero line - svg += `` - svg += `Маржинальность, %` - // Bars - quarters.forEach((q, gi) => { - const cx = PL + gi*grpW + grpW/2 - const items = [ - { key: 'opt', val: q.opt, color: GREEN, offset: -bW*1.15 }, - { key: 'base', val: q.base, color: RED, offset: 0 }, - { key: 'pess', val: q.pess, color: ORANGE, offset: bW*1.15 }, + // Y grid + const step = (maxV - minV) > 30 ? 5 : 5 + for (let v = Math.ceil(minV / step) * step; v <= maxV; v += step) { + const y = yV(v) + svg += `` + svg += `${v}` + } + + // Zero line + svg += `` + svg += `Маржинальность, %` + + items.forEach((q, gi) => { + const cx = PL + gi * grpW + grpW / 2 + const bars = [ + { val: q.opt, color: GREEN, offset: -bW * 1.15 }, + { val: q.base, color: RED, offset: 0 }, + { val: q.pess, color: GRAY_DARK, offset: bW * 1.15 }, ] - items.forEach(it => { + bars.forEach(it => { const bH = Math.abs(yV(it.val) - yV(0)) const by = it.val >= 0 ? yV(it.val) : zeroY - svg += `` + svg += `` const lbY = it.val >= 0 ? by - 6 : by + bH + 14 - svg += `${it.val>0?'+':''}${it.val.toFixed(1)}%` + svg += `${it.val > 0 ? '+' : ''}${it.val.toFixed(1)}%` }) - svg += `${esc(q.label)}` + svg += `${esc(q.label)}` }) // Legend const lx = PL + cW + 16 ;[ - { c: GREEN, txt: 'Оптимистичный' }, - { c: RED, txt: 'Базовый' }, - { c: ORANGE, txt: 'Пессимистичный' }, + { c: GREEN, txt: 'Оптимистичный' }, + { c: RED, txt: 'Базовый' }, + { c: GRAY_DARK, txt: 'Пессимистичный' }, ].forEach((it, i) => { - const ly = PT + 30 + i*28 + const ly = PT + 30 + i * 28 svg += `` - svg += `${esc(it.txt)}` + svg += `${esc(it.txt)}` }) - const fullSvg = ` + const fullSvg = ` DairyTrends · dairy-news.ru/dairytrends ${esc(regionLabel)} diff --git a/src/index.js b/src/index.js index ae40589..b88d6c0 100644 --- a/src/index.js +++ b/src/index.js @@ -50,6 +50,11 @@ async function generateReport (config) { items: data.leaders.items, title: data.leaders.title || 'Топ-производители по чистой прибыли', unit: data.leaders.unit || 'млн руб.', + ...(data.leaders.volumeItems ? { + volumeItems: data.leaders.volumeItems, + volumeTitle: data.leaders.volumeTitle || 'Объём переработки', + volumeUnit: data.leaders.volumeUnit || 'тыс. тонн', + } : {}), regionLabel: `${subject.name} · ${data.leaders.title || 'Лидеры рынка'} · ${data.leaders.unit || 'млн руб.'}` }) }