fix: chart4 — обе панели с Y-осью и сеткой, общая шкала Y

- Обе панели имеют горизонтальную сетку от общей шкалы
- Левая панель: Y-лейблы слева (числа)
- Правая панель: сетка без лейблов (как в оригинале)
- Пунктирный разделитель между панелями
- Подпись единиц под каждой панелью
This commit is contained in:
Alexey Pavlov
2026-06-08 22:46:33 +03:00
parent 8ff48cb5fa
commit 16afbd7d8f
+46 -45
View File
@@ -1,9 +1,8 @@
/**
* Chart 4 — Сравнение регионов: две вертикальные панели рядом.
*
* Левая панель: зелёные бары (лидеры).
* Правая панель: оранжевые/светлые бары (аутсайдеры).
* Выбранный субъект: жёлтый/оранжевый highlight (isHighlight=true).
* Обе панели с Y-осью и сеткой. Общая шкала Y.
* Левая: зелёные бары. Правая: оранжевые. isHighlight=true → жёлтый.
*
* Параметры:
* left: { title, bars: [{ label, value, isHighlight? }] }
@@ -14,92 +13,94 @@
const sharp = require('sharp')
const { PALETTE } = require('../data/palette')
const { dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID, green: GREEN, orange: ORANGE, o: YELLOW } = 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
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 DIVIDER_GAP = 52 // зазор между панелями (пунктирная линия посредине)
const PL = 36, PR = 10 // отступы внутри каждой панели
const PT = 56, PB = 64 // верх/низ (PB — место для подписей оси X + unit)
const cH = 280 // высота области баров
// Общая шкала 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 panelW = (W - DIVIDER_GAP) / 2
const cWp = panelW - PL - PR // ширина области баров в каждой панели
// Общая шкала Y — берём max по обеим панелям
const allVals = [...left.bars.map(b => b.value), ...right.bars.map(b => b.value)]
const rawMax = Math.max(...allVals)
const rawMin = 0
// Красивые деления
const tickStep = rawMax > 40 ? 10 : rawMax > 20 ? 5 : 2
const maxV = Math.ceil(rawMax / tickStep) * tickStep + tickStep
const minV = 0
const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH
function renderPanel (bars, offsetX, title, defaultColor, isLeftPanel) {
// Рисуем одну панель
function renderPanel (bars, offsetX, title, defaultColor, showYLabels) {
const n = bars.length
const cW = panelW - PL - PR
const slotW = cW / n
const barW = Math.min(50, slotW * 0.68)
const slotW = cWp / n
const barW = Math.min(52, slotW * 0.68)
let s = ''
// Заголовок
s += `<text x="${offsetX + PL + cW / 2}" y="${PT - 28}" text-anchor="middle" font-size="13" font-weight="bold" fill="${DARK}">${esc(title)}</text>`
// Заголовок панели
s += `<text x="${(offsetX + PL + cWp / 2).toFixed(1)}" y="${PT - 28}" text-anchor="middle" font-size="13" font-weight="bold" fill="${DARK}">${esc(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"/>`
// Y grid + лейблы
for (let v = 0; v <= maxV; v += tickStep) {
const y = yV(v)
s += `<line x1="${offsetX + PL}" y1="${y.toFixed(1)}" x2="${offsetX + PL + cWp}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
if (showYLabels) {
s += `<text x="${(offsetX + PL - 6).toFixed(1)}" y="${(y + 4).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${v}</text>`
}
}
// Ось 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"/>`
// Ось X (нулевая)
s += `<line x1="${offsetX + PL}" y1="${yV(0).toFixed(1)}" x2="${offsetX + PL + cWp}" y2="${yV(0).toFixed(1)}" stroke="${LTGRAY}" stroke-width="1.5"/>`
// Бары
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)
// Цвет: 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>`
s += `<text x="${cx.toFixed(1)}" y="${(by - 7).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 labelX = cx
const labelY = yV(0) + 12
const maxLen = Math.floor(slotW / 4.6)
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>`
})
// Подпись единиц
s += `<text x="${offsetX + PL + cW / 2}" y="${PT + cH + PB - 4}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${esc(unit)}</text>`
// Подпись единиц под панелью
s += `<text x="${(offsetX + PL + cWp / 2).toFixed(1)}" y="${PT + cH + PB - 6}" text-anchor="middle" font-size="10" fill="${MDGRAY}">${esc(unit)}</text>`
return s
}
const leftSvg = renderPanel(left.bars, 0, left.title, GREEN, true)
const rightSvg = renderPanel(right.bars, panelW + gap, right.title, ORANGE, false)
const leftSvg = renderPanel(left.bars, 0, left.title, GREEN, true)
const rightSvg = renderPanel(right.bars, panelW + DIVIDER_GAP, right.title, ORANGE, false)
// Разделительная линия между панелями
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 divX = panelW + DIVIDER_GAP / 2
const divider = `<line x1="${divX.toFixed(1)}" y1="${PT - 18}" x2="${divX.toFixed(1)}" y2="${PT + cH}" stroke="${LTGRAY}" stroke-width="1" stroke-dasharray="4,4"/>`
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)">${leftSvg}${dividerLine}${rightSvg}</g>
<g transform="translate(0,40)">${leftSvg}${divider}${rightSvg}</g>
</svg>`
return {