feat: chart2/3/4 + section 5 (regional breakdown)
- chart2_leaders: horizontal ranked bars with highlight & category coloring - chart3_ebitdaGroups: vertical bars colored by sign (green ≥0, orange <0) - chart4_regionCompare: side-by-side bar panels with shared y-scale - document.js: new section 5 'Региональный разрез и эффективность' between sections 4 and 6, renders chart2/3/4 with figure captions - index.js: render hooks for data.leaders, data.ebitda, data.regionComparison - examples/russia.js: demo data for all three new charts
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Chart 4 — Сравнение регионов: две вертикальные панели рядом.
|
||||
*
|
||||
* Левая панель (например «Лидеры по цене») и правая («Аутсайдеры»).
|
||||
* Общая шкала Y для честного визуального сравнения. Подсветка субъекта отчёта.
|
||||
*
|
||||
* Параметры:
|
||||
* left: { title, bars: [{ label, value, isHighlight? }] }
|
||||
* right: { title, bars: [{ label, value, isHighlight? }] }
|
||||
* regionLabel: string
|
||||
* unit: string — по умолчанию 'руб./кг'
|
||||
* leftColor / rightColor — опционально (по умолчанию зелёный / оранжевый)
|
||||
* width: number
|
||||
*/
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
// общая шкала по обеим панелям
|
||||
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)
|
||||
const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH
|
||||
const zeroY = yV(0)
|
||||
|
||||
let svg = ''
|
||||
|
||||
// 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>`
|
||||
|
||||
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>`
|
||||
|
||||
const n = panel.bars.length
|
||||
const slot = panelW / n
|
||||
const bW = Math.min(46, slot * 0.62)
|
||||
|
||||
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
|
||||
|
||||
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>`
|
||||
})
|
||||
|
||||
// нулевая ось панели
|
||||
svg += `<line x1="${x0.toFixed(1)}" y1="${zeroY.toFixed(1)}" x2="${(x0+panelW).toFixed(1)}" y2="${zeroY.toFixed(1)}" stroke="${LTGRAY}" stroke-width="1"/>`
|
||||
}
|
||||
|
||||
const leftX = PL
|
||||
const rightX = PL + panelW + gap
|
||||
|
||||
drawPanel(left, leftX, leftColor)
|
||||
drawPanel(right, rightX, rightColor)
|
||||
|
||||
// разделитель между панелями
|
||||
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>
|
||||
<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
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { chart4_regionCompare }
|
||||
Reference in New Issue
Block a user