feat: chart4 — два режима (независимые шкалы vs общая)
independentScales=true (для регионов типа Вологды): - Левая и правая панели с разными показателями (надой vs цена) - Каждая со своей шкалой Y и Y-лейблами - Y-лейблы с тире (как в оригинале: '9500 –') - highlight=красный, остальные=серые independentScales=false (для ФО, по умолчанию): - Общая шкала, зелёный/оранжевый/жёлтый
This commit is contained in:
+149
-71
@@ -1,98 +1,176 @@
|
|||||||
/**
|
/**
|
||||||
* Chart 4 — Сравнение регионов: две вертикальные панели рядом.
|
* Chart 4 — Сравнение регионов: две вертикальные панели рядом.
|
||||||
*
|
*
|
||||||
* Обе панели с Y-осью и сеткой. Общая шкала Y.
|
* Поддерживает два режима:
|
||||||
* Левая: зелёные бары. Правая: оранжевые. isHighlight=true → жёлтый.
|
*
|
||||||
|
* 1. ОБЩАЯ шкала (по умолчанию, для ФО-отчётов):
|
||||||
|
* left и right показывают одно и то же — закупочную цену.
|
||||||
|
* Шкала Y одна на обе панели.
|
||||||
|
* Левая — зелёные бары, правая — оранжевые.
|
||||||
|
*
|
||||||
|
* 2. НЕЗАВИСИМЫЕ шкалы (independentScales: true, для региональных отчётов):
|
||||||
|
* Левая и правая панели с разными показателями (надой vs цена).
|
||||||
|
* У каждой своя шкала Y.
|
||||||
|
* isHighlight → красный, остальные → серые.
|
||||||
*
|
*
|
||||||
* Параметры:
|
* Параметры:
|
||||||
* left: { title, bars: [{ label, value, isHighlight? }] }
|
* left: { title, unit, bars: [{ label, value, isHighlight? }] }
|
||||||
* right: { title, bars: [{ label, value, isHighlight? }] }
|
* right: { title, unit, bars: [{ label, value, isHighlight? }] }
|
||||||
|
* independentScales?: boolean
|
||||||
* regionLabel: string
|
* regionLabel: string
|
||||||
* unit: string
|
* unit?: string — общая единица (когда independentScales=false)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const sharp = require('sharp')
|
const sharp = require('sharp')
|
||||||
const { PALETTE } = require('../data/palette')
|
const { PALETTE } = require('../data/palette')
|
||||||
const { dark: DARK, mdGray: MDGRAY, ltGray: LTGRAY, grid: GRID,
|
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,'<').replace(/>/g,'>') }
|
function esc (s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') }
|
||||||
|
|
||||||
async function chart4_regionCompare ({ left, right, regionLabel, unit = 'руб./кг', width = 920 }) {
|
function niceTicks (minV, maxV, targetCount = 5) {
|
||||||
const W = width
|
const range = maxV - minV
|
||||||
const DIVIDER_GAP = 52 // зазор между панелями (пунктирная линия посредине)
|
const rawStep = range / targetCount
|
||||||
const PL = 36, PR = 10 // отступы внутри каждой панели
|
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
|
||||||
const PT = 56, PB = 64 // верх/низ (PB — место для подписей оси X + unit)
|
const step = [1, 2, 2.5, 5, 10].map(f => f * mag).find(s => s >= rawStep) || rawStep
|
||||||
const cH = 280 // высота области баров
|
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
|
function renderPanel ({
|
||||||
const panelW = (W - DIVIDER_GAP) / 2
|
bars, offsetX, title, unitLabel,
|
||||||
const cWp = panelW - PL - PR // ширина области баров в каждой панели
|
defaultColor, highlightColor,
|
||||||
|
independentScales,
|
||||||
// Общая шкала Y — берём max по обеим панелям
|
cWp, PT, cH, PL, PR,
|
||||||
const allVals = [...left.bars.map(b => b.value), ...right.bars.map(b => b.value)]
|
// Если independentScales — собственный minV/maxV/ticks
|
||||||
const rawMax = Math.max(...allVals)
|
minV, maxV, ticks,
|
||||||
const rawMin = 0
|
showYLabels
|
||||||
// Красивые деления
|
}) {
|
||||||
const tickStep = rawMax > 40 ? 10 : rawMax > 20 ? 5 : 2
|
const n = bars.length
|
||||||
const maxV = Math.ceil(rawMax / tickStep) * tickStep + tickStep
|
const slotW = cWp / n
|
||||||
const minV = 0
|
const barW = Math.min(52, slotW * 0.68)
|
||||||
const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH
|
const yV = v => PT + cH - (v - minV) / (maxV - minV) * cH
|
||||||
|
|
||||||
// Рисуем одну панель
|
let s = ''
|
||||||
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 = ''
|
|
||||||
|
|
||||||
// Заголовок панели
|
// Заголовок
|
||||||
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>`
|
s += `<text x="${(offsetX + PL + cWp / 2).toFixed(1)}" y="${PT - 28}" text-anchor="middle" font-size="12.5" font-weight="bold" fill="${DARK}">${esc(title)}</text>`
|
||||||
|
|
||||||
// Y grid + лейблы
|
// Y grid
|
||||||
for (let v = 0; v <= maxV; v += tickStep) {
|
ticks.forEach(v => {
|
||||||
const y = yV(v)
|
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 (y < PT - 2 || y > PT + cH + 2) return
|
||||||
if (showYLabels) {
|
s += `<line x1="${offsetX + PL}" y1="${y.toFixed(1)}" x2="${offsetX + PL + cWp}" y2="${y.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`
|
||||||
s += `<text x="${(offsetX + PL - 6).toFixed(1)}" y="${(y + 4).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${v}</text>`
|
if (showYLabels) {
|
||||||
}
|
s += `<text x="${(offsetX + PL - 6).toFixed(1)}" y="${(y + 4).toFixed(1)}" text-anchor="end" font-size="10" fill="${MDGRAY}">${v % 1 === 0 ? v : v.toFixed(1)} –</text>`
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Ось X (нулевая)
|
// Y axis label (rotated, левый край)
|
||||||
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"/>`
|
if (unitLabel && showYLabels) {
|
||||||
|
const midY = PT + cH / 2
|
||||||
// Бары
|
s += `<text x="${(offsetX + 12).toFixed(1)}" y="${midY}" text-anchor="middle" font-size="10" fill="${MDGRAY}" transform="rotate(-90,${(offsetX + 12).toFixed(1)},${midY})">${esc(unitLabel)}</text>`
|
||||||
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 += `<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 - 7).toFixed(1)}" text-anchor="middle" font-size="11" font-weight="bold" fill="${color}">${b.value.toFixed(1)}</text>`
|
|
||||||
|
|
||||||
// Подпись под осью (повёрнутая)
|
|
||||||
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 + 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)
|
// Ось X (нулевая или bottom)
|
||||||
const rightSvg = renderPanel(right.bars, panelW + DIVIDER_GAP, right.title, ORANGE, false)
|
s += `<line x1="${offsetX + PL}" y1="${(PT + cH).toFixed(1)}" x2="${offsetX + PL + cWp}" y2="${(PT + cH).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(ticks[0])) // от нижнего тика
|
||||||
|
const by = yV(b.value)
|
||||||
|
const color = b.isHighlight ? highlightColor : defaultColor
|
||||||
|
|
||||||
|
s += `<rect x="${x.toFixed(1)}" y="${by.toFixed(1)}" width="${barW.toFixed(1)}" height="${bH.toFixed(1)}" fill="${color}" rx="2"/>`
|
||||||
|
|
||||||
|
// Значение над баром
|
||||||
|
const dispVal = b.value >= 1000 ? b.value.toLocaleString('ru-RU') : b.value.toFixed(1)
|
||||||
|
s += `<text x="${cx.toFixed(1)}" y="${(by - 7).toFixed(1)}" text-anchor="middle" font-size="11" font-weight="bold" fill="${color}">${dispVal}</text>`
|
||||||
|
|
||||||
|
// Подпись под осью
|
||||||
|
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 += `<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>`
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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 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"/>`
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ async function generateReport (config) {
|
|||||||
charts.chart4 = await chart4_regionCompare({
|
charts.chart4 = await chart4_regionCompare({
|
||||||
left: data.regionComparison.left,
|
left: data.regionComparison.left,
|
||||||
right: data.regionComparison.right,
|
right: data.regionComparison.right,
|
||||||
|
independentScales: data.regionComparison.independentScales || false,
|
||||||
unit: data.regionComparison.unit || 'руб./кг',
|
unit: data.regionComparison.unit || 'руб./кг',
|
||||||
regionLabel: `${subject.name} · Сравнение регионов · ${data.regionComparison.unit || 'руб./кг'}`
|
regionLabel: `${subject.name} · Сравнение регионов · ${data.regionComparison.unit || 'руб./кг'}`
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user