fix: chart2/3/5 — максимально близко к оригиналу
chart2: две панели (левая=горизонт бары с цветом по категории, правая=верт бары объёма) chart3: горизонтальные бары, цвет по группе (А=красный, Б=серый, В=красный приглушённый), легенда chart5: серый пессимистичный (не оранжевый), поддержка q.quarter и q.label
This commit is contained in:
+139
-51
@@ -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,'<').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 += `<text x="${PL}" y="${PT - 34}" font-size="14" font-weight="bold" fill="${DARK}">${esc(title)}</text>`
|
||||
svg += `<text x="${PL}" y="${PT - 18}" font-size="11" fill="${MDGRAY}">${esc(unit)}</text>`
|
||||
let svg = ''
|
||||
svg += `<text x="${PL}" y="${PT - 32}" font-size="13" font-weight="bold" fill="${DARK}">${esc(title)}</text>`
|
||||
svg += `<text x="${PL}" y="${PT - 16}" font-size="11" fill="${MDGRAY}">${esc(unit)}</text>`
|
||||
svg += `<line x1="${PL}" y1="${PT - 6}" x2="${PL}" y2="${PT + sorted.length * ROW_H}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
|
||||
// Ось Y (вертикальная линия)
|
||||
svg += `<line x1="${PL}" y1="${PT - 6}" x2="${PL}" y2="${PT + sorted.length * ROW_H}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
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 += `<rect x="${PL}" y="${bY.toFixed(1)}" width="${bW.toFixed(1)}" height="${bH}" fill="${color}" rx="2"/>`
|
||||
svg += `<text x="${PL - 8}" y="${(bY + bH / 2 + 4).toFixed(1)}" text-anchor="end" font-size="11" fill="${DARK}">${esc(item.name)}</text>`
|
||||
svg += `<text x="${(PL + bW + 7).toFixed(1)}" y="${(bY + bH / 2 + 4).toFixed(1)}" font-size="11" font-weight="bold" fill="${color}">${item.value.toFixed(item.value >= 100 ? 0 : 1)}</text>`
|
||||
})
|
||||
|
||||
// Нижняя ось + подпись единиц
|
||||
svg += `<line x1="${PL}" y1="${PT + sorted.length * ROW_H}" x2="${PL + cW}" y2="${PT + sorted.length * ROW_H}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
svg += `<text x="${PL + cW / 2}" y="${PT + sorted.length * ROW_H + 16}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${esc(unit)}</text>`
|
||||
|
||||
// Легенда
|
||||
svg += `<rect x="${PL + cW - 140}" y="${PT - 30}" width="14" height="10" fill="${RED}" rx="1"/>`
|
||||
svg += `<text x="${PL + cW - 122}" y="${PT - 21}" font-size="10" fill="${DARK}">Переработчик</text>`
|
||||
svg += `<rect x="${PL + cW - 140}" y="${PT - 16}" width="14" height="10" fill="${GRAY}" rx="1"/>`
|
||||
svg += `<text x="${PL + cW - 122}" y="${PT - 7}" font-size="10" fill="${DARK}">Производитель</text>`
|
||||
|
||||
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 += `<text x="${LPL}" y="${LPT - 30}" font-size="13" font-weight="bold" fill="${DARK}">${esc(title)}</text>`
|
||||
leftSvg += `<text x="${LPL}" y="${LPT - 14}" font-size="11" fill="${MDGRAY}">${esc(unit)}</text>`
|
||||
leftSvg += `<line x1="${LPL}" y1="${LPT - 6}" x2="${LPL}" y2="${LPT + sorted.length * ROW_H}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
|
||||
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 += `<rect x="${PL}" y="${bY.toFixed(1)}" width="${bW.toFixed(1)}" height="${bH}" fill="${RED}" rx="2"/>`
|
||||
|
||||
// Лейбл слева
|
||||
svg += `<text x="${PL - 8}" y="${(bY + bH / 2 + 4).toFixed(1)}" text-anchor="end" font-size="11" fill="${DARK}">${esc(item.name)}</text>`
|
||||
|
||||
// Значение справа от бара
|
||||
const valX = PL + bW + 8
|
||||
svg += `<text x="${valX.toFixed(1)}" y="${(bY + bH / 2 + 4).toFixed(1)}" font-size="11" font-weight="bold" fill="${RED}">${item.value.toFixed(1)}</text>`
|
||||
const bW = xL(item.value)
|
||||
const color = (item.category === 'processor') ? RED : GRAY
|
||||
leftSvg += `<rect x="${LPL}" y="${bY.toFixed(1)}" width="${bW.toFixed(1)}" height="${bH}" fill="${color}" rx="2"/>`
|
||||
leftSvg += `<text x="${LPL - 7}" y="${(bY + bH / 2 + 4).toFixed(1)}" text-anchor="end" font-size="10.5" fill="${DARK}">${esc(item.name)}</text>`
|
||||
leftSvg += `<text x="${(LPL + bW + 6).toFixed(1)}" y="${(bY + bH / 2 + 4).toFixed(1)}" font-size="10.5" font-weight="bold" fill="${color}">${item.value.toFixed(item.value >= 100 ? 0 : 1)}</text>`
|
||||
})
|
||||
leftSvg += `<line x1="${LPL}" y1="${LPT + sorted.length * ROW_H}" x2="${LPL + LcW}" y2="${LPT + sorted.length * ROW_H}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
leftSvg += `<text x="${LPL + LcW / 2}" y="${LPT + sorted.length * ROW_H + 16}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${esc(unit)}</text>`
|
||||
// Легенда
|
||||
leftSvg += `<rect x="${LPL}" y="${LPT - 30}" width="11" height="9" fill="${RED}" rx="1"/>`
|
||||
leftSvg += `<text x="${LPL + 15}" y="${LPT - 22}" font-size="10" fill="${DARK}">Переработчик</text>`
|
||||
leftSvg += `<rect x="${LPL + 104}" y="${LPT - 30}" width="11" height="9" fill="${GRAY}" rx="1"/>`
|
||||
leftSvg += `<text x="${LPL + 119}" y="${LPT - 22}" font-size="10" fill="${DARK}">Производитель</text>`
|
||||
|
||||
// Нижняя ось
|
||||
svg += `<line x1="${PL}" y1="${PT + sorted.length * ROW_H}" x2="${PL + cW}" y2="${PT + sorted.length * ROW_H}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
// Подпись единиц снизу
|
||||
svg += `<text x="${PL + cW / 2}" y="${PT + sorted.length * ROW_H + 18}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${esc(unit)}</text>`
|
||||
let rightSvg = ''
|
||||
const rx0 = leftW + gap
|
||||
rightSvg += `<text x="${rx0 + RPL + RcW / 2}" y="${RPT - 30}" text-anchor="middle" font-size="13" font-weight="bold" fill="${DARK}">${esc(volumeTitle)}</text>`
|
||||
rightSvg += `<text x="${rx0 + RPL + RcW / 2}" y="${RPT - 14}" text-anchor="middle" font-size="11" fill="${MDGRAY}">${esc(volumeUnit)}</text>`
|
||||
|
||||
const fullSvg = `<svg width="${W}" height="${H + 40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, '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
|
||||
// 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 += `<line x1="${rx0 + RPL}" y1="${y.toFixed(1)}" x2="${rx0 + RPL + RcW}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
|
||||
rightSvg += `<text x="${rx0 + RPL - 5}" y="${(y + 4).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${v}</text>`
|
||||
}
|
||||
rightSvg += `<line x1="${rx0 + RPL}" y1="${RPT + RcH}" x2="${rx0 + RPL + RcW}" y2="${RPT + RcH}" stroke="${LTGRAY}" stroke-width="1.5"/>`
|
||||
|
||||
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 += `<rect x="${x.toFixed(1)}" y="${by.toFixed(1)}" width="${bWR.toFixed(1)}" height="${bH.toFixed(1)}" fill="${RED}" rx="2"/>`
|
||||
rightSvg += `<text x="${cx.toFixed(1)}" y="${(by - 6).toFixed(1)}" text-anchor="middle" font-size="11" font-weight="bold" fill="${RED}">${item.value.toFixed(1)}</text>`
|
||||
const maxLen = Math.floor(slotWR / 5)
|
||||
const name = item.name.length > maxLen ? item.name.slice(0, maxLen) + '.' : item.name
|
||||
rightSvg += `<text x="${cx.toFixed(1)}" y="${RPT + RcH + 14}" text-anchor="middle" font-size="9.5" fill="${DARK}">${esc(name)}</text>`
|
||||
})
|
||||
rightSvg += `<text x="${rx0 + RPL + RcW / 2}" y="${RPT + RcH + 30}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${esc(volumeUnit)}</text>`
|
||||
|
||||
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 `<svg width="${W}" height="${H + 40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, 'Helvetica Neue', Arial, sans-serif">
|
||||
<rect x="0" y="0" width="3" height="40" fill="#C0272D"/>
|
||||
<text x="14" y="18" font-size="12" font-weight="bold" fill="#C0272D">DairyTrends · dairy-news.ru/dairytrends</text>
|
||||
<text x="14" y="34" font-size="11" fill="#808080">${esc(regionLabel)}</text>
|
||||
<g transform="translate(0,40)">${inner}</g>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
module.exports = { chart2_leaders }
|
||||
|
||||
+63
-44
@@ -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,'<').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 += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${PL + cW}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
|
||||
svg += `<text x="${PL - 6}" y="${(y + 4).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${v}</text>`
|
||||
const x = xV(v)
|
||||
svg += `<line x1="${x.toFixed(1)}" y1="${PT}" x2="${x.toFixed(1)}" y2="${PT + sorted.length * ROW_H}" stroke="${GRID}" stroke-width="1"/>`
|
||||
svg += `<text x="${x.toFixed(1)}" y="${PT + sorted.length * ROW_H + 14}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${v}</text>`
|
||||
}
|
||||
|
||||
// Zero line
|
||||
svg += `<line x1="${PL}" y1="${zeroY.toFixed(1)}" x2="${PL + cW}" y2="${zeroY.toFixed(1)}" stroke="${LTGRAY}" stroke-width="1.5"/>`
|
||||
svg += `<line x1="${zeroX.toFixed(1)}" y1="${PT - 4}" x2="${zeroX.toFixed(1)}" y2="${PT + sorted.length * ROW_H}" stroke="${LTGRAY}" stroke-width="1.5"/>`
|
||||
|
||||
// Y axis label
|
||||
svg += `<text x="12" y="${PT + cH / 2}" text-anchor="middle" font-size="10" fill="${MDGRAY}" transform="rotate(-90,12,${PT + cH / 2})">${esc(unit)}</text>`
|
||||
// X axis label
|
||||
svg += `<text x="${PL + cW / 2}" y="${PT + sorted.length * ROW_H + 24}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${esc(unit)}</text>`
|
||||
|
||||
// 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 += `<rect x="${x.toFixed(1)}" y="${by.toFixed(1)}" width="${barW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${GREEN}" rx="2"/>`
|
||||
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 += `<text x="${cx.toFixed(1)}" y="${lblY.toFixed(1)}" text-anchor="middle" font-size="11" font-weight="bold" fill="${GREEN}">${item.value > 0 ? '+' : ''}${item.value.toFixed(1)}%</text>`
|
||||
svg += `<rect x="${x0.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${bH}" fill="${color}" opacity="${opacity}" rx="2"/>`
|
||||
|
||||
// Под баром: буква группы крупно
|
||||
const baseY = PT + cH + 16
|
||||
svg += `<text x="${cx.toFixed(1)}" y="${baseY}" text-anchor="middle" font-size="14" font-weight="bold" fill="${DARK}">${esc(item.group)}</text>`
|
||||
// Лейбл слева
|
||||
svg += `<text x="${PL - 6}" y="${(y + bH / 2 + 4).toFixed(1)}" text-anchor="end" font-size="10.5" fill="${DARK}">${esc(item.name)}</text>`
|
||||
|
||||
// Название предприятия (мелко, повёрнуто или обрезано)
|
||||
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 += `<text x="${cx.toFixed(1)}" y="${nameY}" text-anchor="middle" font-size="9" fill="${MDGRAY}">${esc(name)}</text>`
|
||||
// Значение у конца бара
|
||||
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 += `<text x="${valX.toFixed(1)}" y="${(y + bH / 2 + 4).toFixed(1)}" text-anchor="${valAnchor}" font-size="10.5" font-weight="bold" fill="${color}" opacity="${opacity}">${sign}${item.value.toFixed(1)}%</text>`
|
||||
})
|
||||
|
||||
// Легенда
|
||||
const lx = PL + cW + 14
|
||||
LEGEND.forEach((leg, i) => {
|
||||
const ly = PT + 8 + i * 22
|
||||
svg += `<rect x="${lx}" y="${ly}" width="14" height="11" fill="${leg.color}" opacity="${leg.opacity}" rx="2"/>`
|
||||
svg += `<text x="${lx + 18}" y="${ly + 10}" font-size="10" fill="${DARK}">${esc(leg.label)}</text>`
|
||||
})
|
||||
|
||||
const fullSvg = `<svg width="${W}" height="${H + 40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, 'Helvetica Neue', Arial, sans-serif">
|
||||
|
||||
+41
-35
@@ -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,'<').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 += `<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>`
|
||||
}
|
||||
// Zero line
|
||||
svg += `<line x1="${PL}" y1="${zeroY.toFixed(1)}" x2="${PL+cW}" y2="${zeroY.toFixed(1)}" stroke="${DARK}" stroke-width="1"/>`
|
||||
svg += `<text x="14" y="${PT+cH/2}" text-anchor="middle" font-size="11" fill="${MDGRAY}" transform="rotate(-90,14,${PT+cH/2})">Маржинальность, %</text>`
|
||||
|
||||
// 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 += `<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>`
|
||||
}
|
||||
|
||||
// Zero line
|
||||
svg += `<line x1="${PL}" y1="${zeroY.toFixed(1)}" x2="${PL + cW}" y2="${zeroY.toFixed(1)}" stroke="${DARK}" stroke-width="1"/>`
|
||||
svg += `<text x="14" y="${PT + cH / 2}" text-anchor="middle" font-size="11" fill="${MDGRAY}" transform="rotate(-90,14,${PT + cH / 2})">Маржинальность, %</text>`
|
||||
|
||||
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 += `<rect x="${(cx+it.offset-bW/2).toFixed(1)}" y="${by.toFixed(1)}" width="${bW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${it.color}" rx="2" opacity="0.9"/>`
|
||||
svg += `<rect x="${(cx + it.offset - bW / 2).toFixed(1)}" y="${by.toFixed(1)}" width="${bW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${it.color}" rx="2" opacity="0.9"/>`
|
||||
const lbY = it.val >= 0 ? by - 6 : by + bH + 14
|
||||
svg += `<text x="${(cx+it.offset).toFixed(1)}" y="${lbY.toFixed(1)}" text-anchor="middle" font-size="10" font-weight="bold" fill="${it.color}">${it.val>0?'+':''}${it.val.toFixed(1)}%</text>`
|
||||
svg += `<text x="${(cx + it.offset).toFixed(1)}" y="${lbY.toFixed(1)}" text-anchor="middle" font-size="10" font-weight="bold" fill="${it.color}">${it.val > 0 ? '+' : ''}${it.val.toFixed(1)}%</text>`
|
||||
})
|
||||
svg += `<text x="${cx.toFixed(1)}" y="${PT+cH+22}" text-anchor="middle" font-size="11" fill="${DARK}">${esc(q.label)}</text>`
|
||||
svg += `<text x="${cx.toFixed(1)}" y="${PT + cH + 22}" text-anchor="middle" font-size="11" fill="${DARK}">${esc(q.label)}</text>`
|
||||
})
|
||||
|
||||
// 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 += `<rect x="${lx}" y="${ly}" width="16" height="12" fill="${it.c}" rx="2" opacity="0.9"/>`
|
||||
svg += `<text x="${lx+22}" y="${ly+10}" font-size="11" fill="${DARK}">${esc(it.txt)}</text>`
|
||||
svg += `<text x="${lx + 22}" y="${ly + 10}" font-size="11" fill="${DARK}">${esc(it.txt)}</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">
|
||||
const fullSvg = `<svg width="${W}" height="${H + 40}" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, '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>
|
||||
|
||||
@@ -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 || 'млн руб.'}`
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user