From 0625d75aa6223e87591af9c2398f73793c6d1015 Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Mon, 8 Jun 2026 22:52:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20chart4=20=E2=80=94=20=D0=B4=D0=B2=D0=B0?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0=20(=D0=BD=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D1=8B=D0=B5=20=D1=88=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8B=20vs=20=D0=BE=D0=B1=D1=89=D0=B0=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit independentScales=true (для регионов типа Вологды): - Левая и правая панели с разными показателями (надой vs цена) - Каждая со своей шкалой Y и Y-лейблами - Y-лейблы с тире (как в оригинале: '9500 –') - highlight=красный, остальные=серые independentScales=false (для ФО, по умолчанию): - Общая шкала, зелёный/оранжевый/жёлтый --- src/charts/chart4.js | 220 +++++++++++++++++++++++++++++-------------- src/index.js | 1 + 2 files changed, 150 insertions(+), 71 deletions(-) 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 || 'руб./кг'}` })