diff --git a/src/charts/chart4.js b/src/charts/chart4.js
index eaa7a25..342ab29 100644
--- a/src/charts/chart4.js
+++ b/src/charts/chart4.js
@@ -1,98 +1,176 @@
/**
* Chart 4 — Сравнение регионов: две вертикальные панели рядом.
*
- * Обе панели с Y-осью и сеткой. Общая шкала Y.
- * Левая: зелёные бары. Правая: оранжевые. isHighlight=true → жёлтый.
+ * Поддерживает два режима:
+ *
+ * 1. ОБЩАЯ шкала (по умолчанию, для ФО-отчётов):
+ * left и right показывают одно и то же — закупочную цену.
+ * Шкала Y одна на обе панели.
+ * Левая — зелёные бары, правая — оранжевые.
+ *
+ * 2. НЕЗАВИСИМЫЕ шкалы (independentScales: true, для региональных отчётов):
+ * Левая и правая панели с разными показателями (надой vs цена).
+ * У каждой своя шкала Y.
+ * isHighlight → красный, остальные → серые.
*
* Параметры:
- * left: { title, bars: [{ label, value, isHighlight? }] }
- * right: { title, bars: [{ label, value, isHighlight? }] }
+ * left: { title, unit, bars: [{ label, value, isHighlight? }] }
+ * right: { title, unit, bars: [{ label, value, isHighlight? }] }
+ * independentScales?: boolean
* regionLabel: string
- * unit: string
+ * unit?: string — общая единица (когда independentScales=false)
*/
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
+ green: GREEN, orange: ORANGE, o: YELLOW, red: RED } = PALETTE
+
+const GRAY = '#B0B0B0'
function esc (s) { return String(s).replace(/&/g,'&').replace(//g,'>') }
-async function chart4_regionCompare ({ left, right, regionLabel, unit = 'руб./кг', width = 920 }) {
- const W = width
- const DIVIDER_GAP = 52 // зазор между панелями (пунктирная линия посредине)
- const PL = 36, PR = 10 // отступы внутри каждой панели
- const PT = 56, PB = 64 // верх/низ (PB — место для подписей оси X + unit)
- const cH = 280 // высота области баров
+function niceTicks (minV, maxV, targetCount = 5) {
+ const range = maxV - minV
+ const rawStep = range / targetCount
+ const mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
+ const step = [1, 2, 2.5, 5, 10].map(f => f * mag).find(s => s >= rawStep) || rawStep
+ const lo = Math.floor(minV / step) * step
+ const hi = Math.ceil(maxV / step) * step
+ const ticks = []
+ for (let v = lo; v <= hi + step * 0.01; v += step) ticks.push(Math.round(v * 1000) / 1000)
+ return ticks
+}
- 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
+function renderPanel ({
+ bars, offsetX, title, unitLabel,
+ defaultColor, highlightColor,
+ independentScales,
+ cWp, PT, cH, PL, PR,
+ // Если independentScales — собственный minV/maxV/ticks
+ minV, maxV, ticks,
+ showYLabels
+}) {
+ const n = bars.length
+ const slotW = cWp / n
+ const barW = Math.min(52, slotW * 0.68)
const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH
- // Рисуем одну панель
- function renderPanel (bars, offsetX, title, defaultColor, showYLabels) {
- const n = bars.length
- const slotW = cWp / n
- const barW = Math.min(52, slotW * 0.68)
- let s = ''
+ let s = ''
- // Заголовок панели
- s += `${esc(title)}`
+ // Заголовок
+ s += `${esc(title)}`
- // Y grid + лейблы
- for (let v = 0; v <= maxV; v += tickStep) {
- const y = yV(v)
- s += ``
- if (showYLabels) {
- s += `${v}`
- }
+ // Y grid
+ ticks.forEach(v => {
+ const y = yV(v)
+ if (y < PT - 2 || y > PT + cH + 2) return
+ s += ``
+ if (showYLabels) {
+ s += `${v % 1 === 0 ? v : v.toFixed(1)} –`
}
+ })
- // Ось X (нулевая)
- s += ``
-
- // Бары
- 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)
-
- const color = b.isHighlight ? YELLOW : defaultColor
-
- s += ``
-
- // Значение над баром
- s += `${b.value.toFixed(1)}`
-
- // Подпись под осью (повёрнутая)
- 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 += `${esc(name)}`
- })
-
- // Подпись единиц под панелью
- s += `${esc(unit)}`
-
- return s
+ // Y axis label (rotated, левый край)
+ if (unitLabel && showYLabels) {
+ const midY = PT + cH / 2
+ s += `${esc(unitLabel)}`
}
- const leftSvg = renderPanel(left.bars, 0, left.title, GREEN, true)
- const rightSvg = renderPanel(right.bars, panelW + DIVIDER_GAP, right.title, ORANGE, false)
+ // Ось X (нулевая или bottom)
+ s += ``
+
+ // Бары
+ 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(ticks[0])) // от нижнего тика
+ const by = yV(b.value)
+ const color = b.isHighlight ? highlightColor : defaultColor
+
+ s += ``
+
+ // Значение над баром
+ const dispVal = b.value >= 1000 ? b.value.toLocaleString('ru-RU') : b.value.toFixed(1)
+ s += `${dispVal}`
+
+ // Подпись под осью
+ const labelX = cx, labelY = PT + cH + 12
+ const maxLen = Math.floor(slotW / 4.5)
+ const name = b.label.length > maxLen ? b.label.slice(0, maxLen) + '…' : b.label
+ s += `${esc(name)}`
+ })
+
+ return s
+}
+
+async function chart4_regionCompare ({
+ left, right,
+ independentScales = false,
+ regionLabel,
+ unit = 'руб./кг',
+ width = 920
+}) {
+ const W = width
+ const DIVIDER_GAP = 52
+ const PL = 44, PR = 10
+ const PT = 56, PB = 64
+ const cH = 270
+ const H = PT + cH + PB
+ const panelW = (W - DIVIDER_GAP) / 2
+ const cWp = panelW - PL - PR
+
+ let leftMin, leftMax, leftTicks, rightMin, rightMax, rightTicks
+ let leftColor, leftHighlight, rightColor, rightHighlight
+
+ if (independentScales) {
+ // Независимые шкалы — у каждой панели своя
+ const leftVals = left.bars.map(b => b.value)
+ const rightVals = right.bars.map(b => b.value)
+ leftMin = Math.min(...leftVals)
+ leftMax = Math.max(...leftVals)
+ rightMin = Math.min(...rightVals)
+ rightMax = Math.max(...rightVals)
+ leftTicks = niceTicks(leftMin * 0.93, leftMax * 1.04, 5)
+ rightTicks = niceTicks(rightMin * 0.93, rightMax * 1.04, 5)
+ leftMin = leftTicks[0]; leftMax = leftTicks[leftTicks.length - 1]
+ rightMin = rightTicks[0]; rightMax = rightTicks[rightTicks.length - 1]
+ // Highlight=красный, остальные=серые
+ leftColor = GRAY; leftHighlight = RED
+ rightColor = GRAY; rightHighlight = RED
+ } else {
+ // Общая шкала
+ const allVals = [...left.bars.map(b => b.value), ...right.bars.map(b => b.value)]
+ const globalMin = 0, globalMax = Math.max(...allVals)
+ leftTicks = rightTicks = niceTicks(globalMin, globalMax * 1.12, 5)
+ leftMin = rightMin = leftTicks[0]
+ leftMax = rightMax = leftTicks[leftTicks.length - 1]
+ // Зелёный/оранжевый, highlight=жёлтый
+ leftColor = GREEN; leftHighlight = YELLOW
+ rightColor = ORANGE; rightHighlight = YELLOW
+ }
+
+ const leftUnit = left.unit || (independentScales ? '' : unit)
+ const rightUnit = right.unit || (independentScales ? '' : unit)
+
+ const leftSvg = renderPanel({
+ bars: left.bars, offsetX: 0,
+ title: left.title, unitLabel: leftUnit,
+ defaultColor: leftColor, highlightColor: leftHighlight,
+ independentScales, cWp, PT, cH, PL, PR,
+ minV: leftMin, maxV: leftMax, ticks: leftTicks,
+ showYLabels: true,
+ })
+
+ const rightSvg = renderPanel({
+ bars: right.bars, offsetX: panelW + DIVIDER_GAP,
+ title: right.title, unitLabel: rightUnit,
+ defaultColor: rightColor, highlightColor: rightHighlight,
+ independentScales, cWp, PT, cH, PL, PR,
+ minV: rightMin, maxV: rightMax, ticks: rightTicks,
+ showYLabels: independentScales, // у правой тоже лейблы когда независимые шкалы
+ })
- // Пунктирный разделитель между панелями
const divX = panelW + DIVIDER_GAP / 2
const divider = ``
diff --git a/src/index.js b/src/index.js
index b88d6c0..f52ef46 100644
--- a/src/index.js
+++ b/src/index.js
@@ -71,6 +71,7 @@ async function generateReport (config) {
charts.chart4 = await chart4_regionCompare({
left: data.regionComparison.left,
right: data.regionComparison.right,
+ independentScales: data.regionComparison.independentScales || false,
unit: data.regionComparison.unit || 'руб./кг',
regionLabel: `${subject.name} · Сравнение регионов · ${data.regionComparison.unit || 'руб./кг'}`
})