fix: chart2/3/4 — дизайн как в оригинале
chart2: все бары красные, значение справа жирным chart3: все бары зелёные, буква группы под баром, значение +X% над баром chart4: левая=зелёные, правая=оранжевые, isHighlight=жёлтый (выбранный ФО)
This commit is contained in:
+43
-79
@@ -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,'<').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 += `<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>`
|
||||
// Заголовок панели
|
||||
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>`
|
||||
|
||||
// бары
|
||||
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 += `<line x1="${PL}" y1="${PT - 6}" x2="${PL}" y2="${PT + sorted.length * ROW_H}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
|
||||
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 += `<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 += `<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>`
|
||||
})
|
||||
|
||||
// нулевая/левая ось
|
||||
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"/>`
|
||||
// Нижняя ось
|
||||
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 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">
|
||||
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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+53
-47
@@ -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,'<').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 += `<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 += `<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>`
|
||||
}
|
||||
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"/>`
|
||||
// Zero line
|
||||
svg += `<line x1="${PL}" y1="${zeroY.toFixed(1)}" x2="${PL + cW}" y2="${zeroY.toFixed(1)}" stroke="${LTGRAY}" stroke-width="1.5"/>`
|
||||
|
||||
// бары
|
||||
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 += `<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>`
|
||||
|
||||
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"/>`
|
||||
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 += `<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 += `<rect x="${x.toFixed(1)}" y="${by.toFixed(1)}" width="${barW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${GREEN}" rx="2"/>`
|
||||
|
||||
// буква группы крупно
|
||||
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 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>`
|
||||
|
||||
// Под баром: буква группы крупно
|
||||
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>`
|
||||
|
||||
// Название предприятия (мелко, повёрнуто или обрезано)
|
||||
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 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>
|
||||
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="#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="${MDGRAY}">${esc(regionLabel)}</text>
|
||||
<g transform="translate(0,40)">${svg}</g>
|
||||
</svg>`
|
||||
@@ -83,4 +89,4 @@ async function chart3_ebitdaGroups ({
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { chart3_ebitdaGroups }
|
||||
module.exports = { chart3_ebitdaByOrg }
|
||||
|
||||
+74
-64
@@ -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,'<').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 += `<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>`
|
||||
// Заголовок
|
||||
s += `<text x="${offsetX + PL + cW / 2}" y="${PT - 28}" text-anchor="middle" font-size="13" font-weight="bold" fill="${DARK}">${esc(title)}</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>`
|
||||
// Y grid + labels (только у левой панели)
|
||||
if (isLeftPanel) {
|
||||
const step = maxV > 50 ? 10 : 5
|
||||
for (let v = 0; v <= maxV; v += step) {
|
||||
const y = yV(v)
|
||||
s += `<line x1="${offsetX + PL}" y1="${y.toFixed(1)}" x2="${offsetX + PL + cW}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
|
||||
s += `<text x="${offsetX + PL - 5}" y="${(y + 4).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${v}</text>`
|
||||
}
|
||||
} else {
|
||||
const step = maxV > 50 ? 10 : 5
|
||||
for (let v = 0; v <= maxV; v += step) {
|
||||
const y = yV(v)
|
||||
s += `<line x1="${offsetX + PL}" y1="${y.toFixed(1)}" x2="${offsetX + PL + cW}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
|
||||
}
|
||||
}
|
||||
|
||||
const n = panel.bars.length
|
||||
const slot = panelW / n
|
||||
const bW = Math.min(46, slot * 0.62)
|
||||
// Ось X внизу
|
||||
s += `<line x1="${offsetX + PL}" y1="${yV(0).toFixed(1)}" x2="${offsetX + PL + cW}" y2="${yV(0).toFixed(1)}" stroke="${LTGRAY}" stroke-width="1.5"/>`
|
||||
|
||||
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 += `<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>`
|
||||
// Цвет: highlight → жёлтый, иначе — defaultColor
|
||||
const color = b.isHighlight ? YELLOW : defaultColor
|
||||
|
||||
s += `<rect x="${x.toFixed(1)}" y="${by.toFixed(1)}" width="${barW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${color}" rx="2"/>`
|
||||
|
||||
// Значение над баром
|
||||
s += `<text x="${cx.toFixed(1)}" y="${(by - 6).toFixed(1)}" text-anchor="middle" font-size="11" font-weight="bold" fill="${color}">${b.value.toFixed(1)}</text>`
|
||||
|
||||
// Подпись под осью (повёрнутая)
|
||||
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 += `<text x="${labelX.toFixed(1)}" y="${labelY}" text-anchor="end" font-size="10" fill="${DARK}" transform="rotate(-40,${labelX.toFixed(1)},${labelY})">${esc(name)}</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"/>`
|
||||
// Подпись единиц
|
||||
s += `<text x="${offsetX + PL + cW / 2}" y="${PT + cH + PB - 4}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${esc(unit)}</text>`
|
||||
|
||||
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 = `<line x1="${divX}" y1="${PT - 20}" x2="${divX}" y2="${PT + cH}" stroke="${LTGRAY}" stroke-width="1" stroke-dasharray="4,4"/>`
|
||||
|
||||
// разделитель между панелями
|
||||
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>
|
||||
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="#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="${MDGRAY}">${esc(regionLabel)}</text>
|
||||
<g transform="translate(0,40)">${svg}</g>
|
||||
<g transform="translate(0,40)">${leftSvg}${dividerLine}${rightSvg}</g>
|
||||
</svg>`
|
||||
|
||||
return {
|
||||
|
||||
+7
-9
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user