From c8134442d3eacae24844c8e456e2b6c306626291 Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Mon, 8 Jun 2026 22:15:54 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20chart2/3/4=20=E2=80=94=20=D0=B4=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D0=B9=D0=BD=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B3=D0=B8=D0=BD=D0=B0=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chart2: все бары красные, значение справа жирным chart3: все бары зелёные, буква группы под баром, значение +X% над баром chart4: левая=зелёные, правая=оранжевые, isHighlight=жёлтый (выбранный ФО) --- src/charts/chart2.js | 122 ++++++++++++++------------------------ src/charts/chart3.js | 100 ++++++++++++++++--------------- src/charts/chart4.js | 138 +++++++++++++++++++++++-------------------- src/index.js | 16 +++-- 4 files changed, 177 insertions(+), 199 deletions(-) diff --git a/src/charts/chart2.js b/src/charts/chart2.js index 9ec232c..a267731 100644 --- a/src/charts/chart2.js +++ b/src/charts/chart2.js @@ -1,101 +1,65 @@ /** - * Chart 2 — Горизонтальные бары лидеров (переработчики / производители / регионы). - * - * Универсальный ranked-barchart: сортировка по убыванию, подпись значения справа, - * опциональная подсветка субъекта отчёта и опциональные категории (легенда). - * - * Параметры: - * items: [{ name, value, category?, isHighlight? }] - * regionLabel: string - * unit: string — единица измерения (по умолчанию 'руб./кг') - * categories: { ключ: { color, label } } — опционально, для легенды/окраски - * highlightName: string — имя строки для подсветки (альтернатива isHighlight) - * sort: bool — сортировать по убыванию (по умолчанию true) - * width: number + * Chart 2 — Горизонтальные бары лидеров. + * Все бары красные. Значение справа от бара жирным тем же цветом. + * Под заголовком — подзаголовок (unit). */ 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 +const { red: RED, dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID } = 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]) - +/** + * items: [{ name: string, value: number }] + * title: string — заголовок панели + * unit: string — единица измерения (в подзаголовке) + * regionLabel: string + */ +async function chart2_leaders ({ items, title, unit = 'млн руб.', regionLabel, width = 920 }) { + const sorted = [...items].sort((a, b) => b.value - a.value) 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 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 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) + const maxV = Math.max(...sorted.map(i => i.value)) * 1.1 + const xV = v => (v / maxV) * cW 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)}` + // Заголовок панели + svg += `${esc(title)}` + 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 + // Ось Y (вертикальная линия) + svg += `` - const bx = r.value >= 0 ? zeroX : xV(r.value) - const bw = Math.abs(xV(r.value) - zeroX) + sorted.forEach((item, i) => { + const y = PT + i * ROW_H + const bH = 18 + const bY = y + (ROW_H - bH) / 2 + const bW = xV(item.value) - // подпись имени слева - 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 += `` + + // Лейбл слева + svg += `${esc(item.name)}` + + // Значение справа от бара + const valX = PL + bW + 8 + svg += `${item.value.toFixed(1)}` }) - // нулевая/левая ось - svg += `` + // Нижняя ось + svg += `` + // Подпись единиц снизу + svg += `${esc(unit)}` - // легенда категорий (если заданы) - 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 = ` + const fullSvg = ` DairyTrends · dairy-news.ru/dairytrends ${esc(regionLabel)} @@ -105,7 +69,7 @@ async function chart2_leaders ({ return { buffer: await sharp(Buffer.from(fullSvg)).png({ quality: 95 }).toBuffer(), width: W, - height: totalH + 40 + height: H + 40 } } diff --git a/src/charts/chart3.js b/src/charts/chart3.js index 7aaa060..8e9cbd0 100644 --- a/src/charts/chart3.js +++ b/src/charts/chart3.js @@ -1,77 +1,83 @@ /** - * Chart 3 — EBITDA-маржа по группам эффективности (A / B / C). + * Chart 3 — EBITDA-маржа по предприятиям. * - * Вертикальные бары; цвет по знаку (зелёный — прибыль, оранжевый — убыток). - * Под каждым баром — буква группы крупно + подпись (доля/описание). + * Вертикальные бары: все ЗЕЛЁНЫЕ (как в оригинале). + * Над каждым баром — значение "+14.8%" жирным зелёным. + * Под каждым баром — буква группы (А/Б/В) крупно, затем название предприятия мелко. * * Параметры: - * groups: [{ group: 'A', value: 18.5, label?: 'Эффективные', share?: '25%' }] - * regionLabel: string - * unit: string — по умолчанию 'EBITDA-маржа, %' - * width: number + * items: [{ label: string, value: number, group: 'А'|'Б'|'В' }] + * regionLabel: string */ 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 { dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, green: GREEN } = 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 +async function chart3_ebitdaByOrg ({ items, regionLabel, unit = 'EBITDA-маржа, %', width = 920 }) { + // Сортируем по возрастанию (от минимального к максимальному — как в оригинале) + const sorted = [...items].sort((a, b) => a.value - b.value) - const vals = groups.map(g => g.value) - const maxV = Math.max(...vals, 5) + 4 - const minV = Math.min(...vals, 0) - 4 + const W = width + const PL = 40, PR = 20, PT = 50, PB = 80 // PB большой — место под метки + const cW = W - PL - PR, cH = 240 + + const H = PT + cH + PB + + 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 grpW = cW / groups.length - const bW = Math.min(80, grpW * 0.5) + const n = sorted.length + const slotW = cW / n + const barW = Math.min(48, slotW * 0.65) let svg = '' - // Y-грид - const step = (maxV - minV) > 40 ? 10 : 5 + // Y grid + const step = maxV <= 25 ? 5 : 10 for (let v = Math.ceil(minV / step) * step; v <= maxV; v += step) { const y = yV(v) - svg += `` - svg += `${v}` + svg += `` + svg += `${v}` } - svg += `${esc(unit)}` - // нулевая линия - svg += `` + // Zero line + 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 + // Y axis label + svg += `${esc(unit)}` - svg += `` + 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 lbY = g.value >= 0 ? by - 8 : by + bH + 16 - svg += `${g.value > 0 ? '+' : ''}${g.value.toFixed(1)}%` + // Бар (всегда зелёный как в оригинале) + svg += `` - // буква группы крупно - svg += `${esc(g.group)}` - // подпись группы - if (g.label) svg += `${esc(g.label)}` - // доля - if (g.share) svg += `${esc(g.share)} хозяйств` + // Значение над баром + const lblY = item.value >= 0 ? by - 6 : by + bH + 14 + svg += `${item.value > 0 ? '+' : ''}${item.value.toFixed(1)}%` + + // Под баром: буква группы крупно + const baseY = PT + cH + 16 + svg += `${esc(item.group)}` + + // Название предприятия (мелко, повёрнуто или обрезано) + 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 fullSvg = ` - - DairyTrends · dairy-news.ru/dairytrends + const fullSvg = ` + + DairyTrends · dairy-news.ru/dairytrends ${esc(regionLabel)} ${svg} ` @@ -83,4 +89,4 @@ async function chart3_ebitdaGroups ({ } } -module.exports = { chart3_ebitdaGroups } +module.exports = { chart3_ebitdaByOrg } diff --git a/src/charts/chart4.js b/src/charts/chart4.js index f72df38..dfd7984 100644 --- a/src/charts/chart4.js +++ b/src/charts/chart4.js @@ -1,95 +1,105 @@ /** * Chart 4 — Сравнение регионов: две вертикальные панели рядом. * - * Левая панель (например «Лидеры по цене») и правая («Аутсайдеры»). - * Общая шкала Y для честного визуального сравнения. Подсветка субъекта отчёта. + * Левая панель: зелёные бары (лидеры). + * Правая панель: оранжевые/светлые бары (аутсайдеры). + * Выбранный субъект: жёлтый/оранжевый highlight (isHighlight=true). * * Параметры: - * left: { title, bars: [{ label, value, isHighlight? }] } - * right: { title, bars: [{ label, value, isHighlight? }] } + * left: { title, bars: [{ label, value, isHighlight? }] } + * right: { title, bars: [{ label, value, isHighlight? }] } * regionLabel: string - * unit: string — по умолчанию 'руб./кг' - * leftColor / rightColor — опционально (по умолчанию зелёный / оранжевый) - * width: number + * unit: string */ 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 +const { dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, green: GREEN, orange: ORANGE, o: YELLOW } = 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 +async function chart4_regionCompare ({ left, right, regionLabel, unit = 'руб./кг', width = 920 }) { + const W = width + const gap = 48 + const panelW = (W - gap) / 2 + const PL = 28, PR = 12, PT = 50, PB = 64 + const cH = 260 - // общая шкала по обеим панелям - 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) + // Общая шкала Y для честного сравнения + const allVals = [...left.bars.map(b => b.value), ...right.bars.map(b => b.value)] + const maxV = Math.max(...allVals) * 1.12 + const minV = 0 + const H = PT + cH + PB const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH - const zeroY = yV(0) - let svg = '' + function renderPanel (bars, offsetX, title, defaultColor, isLeftPanel) { + const n = bars.length + const cW = panelW - PL - PR + const slotW = cW / n + const barW = Math.min(50, slotW * 0.68) + let s = '' - // 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)}` + // Заголовок + s += `${esc(title)}` - const drawPanel = (panel, x0, baseColor) => { - // заголовок панели - svg += `${esc(panel.title)}` + // Y grid + labels (только у левой панели) + if (isLeftPanel) { + const step = maxV > 50 ? 10 : 5 + for (let v = 0; v <= maxV; v += step) { + const y = yV(v) + s += `` + s += `${v}` + } + } else { + const step = maxV > 50 ? 10 : 5 + for (let v = 0; v <= maxV; v += step) { + const y = yV(v) + s += `` + } + } - const n = panel.bars.length - const slot = panelW / n - const bW = Math.min(46, slot * 0.62) + // Ось X внизу + s += `` - 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 + bars.forEach((b, i) => { + const cx = offsetX + PL + i * slotW + slotW / 2 + const x = cx - barW / 2 + const bH = Math.abs(yV(b.value) - yV(0)) + const by = yV(b.value) - svg += `` - // значение над баром - svg += `${b.value.toFixed(1)}` - // подпись региона (с переносом-наклоном для длинных) - svg += `${esc(b.label)}` + // Цвет: highlight → жёлтый, иначе — defaultColor + const color = b.isHighlight ? YELLOW : defaultColor + + s += `` + + // Значение над баром + s += `${b.value.toFixed(1)}` + + // Подпись под осью (повёрнутая) + const labelX = cx, labelY = yV(0) + 12 + const maxLen = Math.floor(slotW / 4.8) + const name = b.label.length > maxLen ? b.label.slice(0, maxLen) + '…' : b.label + s += `${esc(name)}` }) - // нулевая ось панели - svg += `` + // Подпись единиц + s += `${esc(unit)}` + + return s } - const leftX = PL - const rightX = PL + panelW + gap + const leftSvg = renderPanel(left.bars, 0, left.title, GREEN, true) + const rightSvg = renderPanel(right.bars, panelW + gap, right.title, ORANGE, false) - drawPanel(left, leftX, leftColor) - drawPanel(right, rightX, rightColor) + // Разделительная линия между панелями + const divX = panelW + gap / 2 + const dividerLine = `` - // разделитель между панелями - const sepX = leftX + panelW + gap/2 - svg += `` - - const fullSvg = ` - - DairyTrends · dairy-news.ru/dairytrends + const fullSvg = ` + + DairyTrends · dairy-news.ru/dairytrends ${esc(regionLabel)} - ${svg} + ${leftSvg}${dividerLine}${rightSvg} ` return { diff --git a/src/index.js b/src/index.js index 727589a..ae40589 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,7 @@ const { chart1_priceAndMargin } = require('./charts/chart1') const { chart2_leaders } = require('./charts/chart2') -const { chart3_ebitdaGroups } = require('./charts/chart3') +const { chart3_ebitdaByOrg } = require('./charts/chart3') const { chart4_regionCompare } = require('./charts/chart4') const { chart5_scenarioMargins } = require('./charts/chart5') const { buildDocument } = require('./generators/document') @@ -48,17 +48,15 @@ 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 || 'руб./кг'}` + title: data.leaders.title || 'Топ-производители по чистой прибыли', + unit: data.leaders.unit || 'млн руб.', + regionLabel: `${subject.name} · ${data.leaders.title || 'Лидеры рынка'} · ${data.leaders.unit || 'млн руб.'}` }) } if (data.ebitda) { - charts.chart3 = await chart3_ebitdaGroups({ - groups: data.ebitda.groups || data.ebitda, + charts.chart3 = await chart3_ebitdaByOrg({ + items: data.ebitda.groups || data.ebitda, unit: (data.ebitda.unit) || 'EBITDA-маржа, %', regionLabel: `${subject.name} · Эффективность по группам хозяйств · %` }) @@ -84,7 +82,7 @@ module.exports = { charts: { chart1_priceAndMargin, chart2_leaders, - chart3_ebitdaGroups, + chart3_ebitdaByOrg, chart4_regionCompare, chart5_scenarioMargins, }