<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>ОВС ($ млрд) · Группа и компании — компактные бары</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- 1. Подключаем шрифт Montserrat -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;900&display=swap" rel="stylesheet">
<style>
/* 2. Применяем шрифт ко всей странице */
body { margin:0; font-family: 'Montserrat', system-ui, -apple-system, sans-serif; background:#fafafa; }
.grid { display:flex; flex-direction:column; gap:12px; padding:12px 8px; }
/* Оставляем боковую колонку (flex + row-aside) */
.row { display:flex; align-items:stretch; gap:12px; }
.row-aside{
flex:0 0 220px; display:flex; align-items:center; justify-content:flex-start;
padding:0 8px 0 4px;
}
.row-title{
font-size:22px; font-weight:700; color:#2F454F; letter-spacing:.2px; line-height:1.2;
}
.row-title.group{ font-size:24px; font-weight:800; letter-spacing:.3px; }
.row-main{ flex:1 1 auto; }
.card { background:#fff; border-radius:14px; box-shadow:0 2px 12px rgba(0,0,0,.06); padding:14px 22px 18px; }
.chart { width:100%; height:240px; }
@media (max-width:720px) {
.grid { gap:14px; padding:12px 8px; }
/* На мобильных сворачиваем в колонку */
.row { flex-direction:column; gap:6px; }
.row-aside{ flex:0 0 auto; padding:0 2px; }
.row-title{ font-size:20px; }
.row-title.group{ font-size:22px; }
.chart { height:240px; }
}
svg text { paint-order: stroke fill; }
</style>
</head>
<body>
<div id="data" style="display:none">{{ data.rows|dump|safe }}</div>
<div class="grid" id="grid"></div>
<script src="/proxy/file-storage/api/v1/storage/ce26108b-18b4-4f8b-9650-8a275913d9df/135fd9c6-e885-42ba-bf4f-b1d0b82f2462/assets/echarts.min.js"></script>
<script>
;(() => {
// Глобальный массив для хранения инстансов графиков
let charts = [];
// Настраиваем заголовок с Rich Text
const TITLE_TEXT = 'Объем внешнеторговых сделок';
const UNIT_TEXT = '($ млрд)';
const TITLE_RICH = `{main|${TITLE_TEXT}} {unit|${UNIT_TEXT}}`;
// Форматтер для Факта
const nfFact = v => {
if (v == null || isNaN(v)) return '';
const abs = Math.abs(v);
const opts = { minimumFractionDigits: abs < 1 ? 3 : 1, maximumFractionDigits: abs < 1 ? 3 : 1 };
return new Intl.NumberFormat('ru-RU', opts).format(v);
};
// Форматтер для Плана
const nfPlan = v => {
if (v == null || isNaN(v)) return '';
return new Intl.NumberFormat('ru-RU').format(v);
};
const pctColor = p => p < 80 ? '#E74C3C' : (p < 90 ? '#F1C40F' : '#2ECC71');
const ymKey = (y,m) => `${y}-${String(m).padStart(2,'0')}`;
const MANUAL = {
"Группа РЭЦ": {
plan: { "3М": 1.6, "6М": 4.8, "9М": 8.7, "12М": 16.2 },
},
"ЭКСАР": {
plan: { "3М": 1.6, "6М": 4.8, "9М": 8.7, "12М": 15.9 },
},
"РЭБ": {
plan: { "3М": 0.2, "6М": 0.5, "9М": 0.9, "12М": 1.6 },
}
};
const quarterOf = (date) => { const m=date.getMonth()+1; return m<=3?1:m<=6?2:m<=9?3:4; };
function horizonsForQuarter(q){
if(q===1)return[
{type:'fact',label:'Факт'},
{type:'plan',label:'План 3М', months:3, isQuarterPlan: true},
{type:'plan',label:'План 12М',months:12, isYearPlan: true},
{type:'fcst',label:'Прогноз 3М',months:3}
];
if(q===2)return[
{type:'fact',label:'Факт'},
{type:'plan',label:'План 6М', months:6, isQuarterPlan: true},
{type:'plan',label:'План 12М',months:12, isYearPlan: true},
{type:'fcst',label:'Прогноз 6М',months:6}
];
if(q===3)return[
{type:'fact',label:'Факт'},
{type:'plan',label:'План 9М', months:9, isQuarterPlan: true},
{type:'plan',label:'План 12М',months:12, isYearPlan: true},
{type:'fcst',label:'Прогноз 9М',months:9}
];
return[
{type:'fact',label:'Факт'},
{type:'plan',label:'План 12М',months:12, isYearPlan: true, isQuarterPlan: true},
{type:'fcst',label:'Прогноз 12М',months:12}
];
}
function getRowsFromContainer(){
const el = document.querySelector('#data');
let raw = (el?.textContent || '').trim();
if (!raw) return [];
if(raw.includes('"')) raw = raw.replace(/"/g, '"');
let arr;
try { arr = JSON.parse(raw); } catch { return []; }
if(!Array.isArray(arr)) arr = arr?.rows ?? [];
if(!Array.isArray(arr)) return [];
return arr.map(r => ({
support_date_year: Number(r["1"]),
support_date_month: Number(r["2"]),
quart: Number(r["3"]),
src: String(r["4"] ?? '').trim(),
ved_direction: String(r["5"] ?? '').trim(),
sev_exporter_mlrd: Number(r["6"] ?? 0) || 0,
sev_group_mlrd: Number(r["7"] ?? 0) || 0
}));
}
function buildMonthlyAgg(rows, companySrc){
const monthly = {};
const monthlyImport = {};
const asOf = new Date();
const year = asOf.getFullYear();
const mNow = asOf.getMonth() + 1;
rows.forEach(r => {
if(r.src !== companySrc) return;
if(r.support_date_year !== year) return;
const ym = ymKey(r.support_date_year, r.support_date_month);
const val = r.sev_exporter_mlrd || 0;
monthly[ym] = (monthly[ym] ?? 0) + val;
if(r.ved_direction === 'Импорт')
monthlyImport[ym] = (monthlyImport[ym] ?? 0) + val;
});
const ymNow = ymKey(year, mNow);
const ymPrev = mNow > 1 ? ymKey(year, mNow - 1) : null;
let cum = 0, cumImp = 0;
const ytd = {}, ytdImport = {};
for (let m = 1; m <= mNow; m++) {
const ym = ymKey(year, m);
cum += monthly[ym] ?? 0;
cumImp += monthlyImport[ym] ?? 0;
ytd[ym] = cum;
ytdImport[ym] = cumImp;
}
const isFirstMonth = mNow === 1 || (ymPrev && (ytd[ymPrev] ?? 0) === 0);
return {
curCum: ytd[ymNow] ?? 0,
prevCum: ymPrev ? (ytd[ymPrev] ?? 0) : 0,
importCum: ytdImport[ymNow] ?? 0,
lastMonth: mNow,
isFirstMonth: isFirstMonth
};
}
function buildMonthlyAggGeneric(rows, key){
const monthly = {};
const monthlyImport = {};
const asOf = new Date();
const year = asOf.getFullYear();
const mNow = asOf.getMonth() + 1;
rows.forEach(r => {
if(r.support_date_year !== year) return;
const ym = ymKey(r.support_date_year, r.support_date_month);
const v = r[key] || 0;
monthly[ym] = (monthly[ym] ?? 0) + v;
if(r.ved_direction === 'Импорт')
monthlyImport[ym] = (monthlyImport[ym] ?? 0) + v;
});
const ymNow = ymKey(year, mNow);
const ymPrev = mNow > 1 ? ymKey(year, mNow - 1) : null;
let cum = 0, cumImp = 0;
const ytd = {}, ytdImport = {};
for (let m = 1; m <= mNow; m++) {
const ym = ymKey(year, m);
cum += monthly[ym] ?? 0;
cumImp += monthlyImport[ym] ?? 0;
ytd[ym] = cum;
ytdImport[ym] = cumImp;
}
const isFirstMonth = mNow === 1 || (ymPrev && (ytd[ymPrev] ?? 0) === 0);
return {
curCum: ytd[ymNow] ?? 0,
prevCum: ymPrev ? (ytd[ymPrev] ?? 0) : 0,
importCum: ytdImport[ymNow] ?? 0,
lastMonth: mNow,
isFirstMonth: isFirstMonth
};
}
function buildBars(monthAgg, src){
const factCum = monthAgg.curCum;
const deltaMoM = monthAgg.isFirstMonth ? 0 : (factCum - (monthAgg.prevCum ?? 0));
const importCum = monthAgg.importCum;
const q = quarterOf(new Date());
const manual = MANUAL[src] || {};
const plan = manual.plan || {};
const fcst = manual.forecast || {};
const steps = horizonsForQuarter(q);
const labels=[], values=[];
let plan12 = null;
let planQ = null;
steps.forEach(s=>{
if(s.type==='fact'){
labels.push('Факт');
values.push(factCum);
}
else if(s.type==='plan'){
const key = `${s.months}М`;
if(plan[key]!=null){
const v = plan[key];
labels.push(s.label);
values.push(v);
if(s.isYearPlan) plan12 = v;
if(s.isQuarterPlan) planQ = v;
}
} else if(s.type==='fcst'){
const key = `${s.months}М`;
if(fcst[key]!=null){
labels.push(s.label);
values.push(fcst[key]);
}
}
});
const pctYear = plan12 > 0 ? (factCum / plan12 * 100) : 0;
const pctQuarter = planQ > 0 ? (factCum / planQ * 100) : 0;
return { labels, values, deltaMoM, pctYear, pctQuarter, importCum };
}
function tooltipRightPosition(pt, params, dom, rect, size) {
const [x0, y0] = pt;
const vw = size.viewSize[0];
const vh = size.viewSize[1];
const tw = size.contentSize[0];
const th = size.contentSize[1];
let x = x0 + 14;
let y = y0 - th / 2;
if (x + tw + 10 > vw) x = vw - tw - 10;
if (y < 8) y = 8;
if (y + th + 8 > vh) y = vh - th - 8;
return [x, y];
}
function createDonut(pct, label, bottomOffset, rightOffset = 0) {
const ringValue = Math.round(Math.max(0, pct));
const ringColor = pctColor(ringValue);
return {
type: 'group',
right: rightOffset,
bottom: bottomOffset,
z: 200,
silent: true,
children: [
{
type: 'arc',
shape: { cx: 0, cy: 0, r: 30, startAngle: 0, endAngle: Math.PI * 2 },
style: { stroke: '#E6E6E6', lineWidth: 5, fill: 'none' }
},
{
type: 'arc',
shape: { cx: 0, cy: 0, r: 30, startAngle: -Math.PI / 2, endAngle: -Math.PI / 2 + 2 * Math.PI * (ringValue / 100) },
style: { stroke: ringColor, lineWidth: 5, fill: 'none' }
},
{
type: 'text',
style: {
x: 0, y: 0,
text: `${ringValue}%`,
fill: ringColor,
fontSize: 16,
fontWeight: '500',
textAlign: 'center',
textVerticalAlign: 'middle',
fontFamily: 'Montserrat'
}
}
]
};
}
function buildOption({ labels, values, deltaMoM, pctYear, pctQuarter, importCum }){
const colors=[
{c1:'#02B050', c2:'#02B050'}, // Факт
{c1:'#0327CE', c2:'#0327CE'}, // План
{c1:'#5C5C5C', c2:'#5C5C5C'}, // доп
{c1:'#8CA1A5', c2:'#C5D0D2'} // Прогноз
];
const donutQuarter = createDonut(pctQuarter, 'Квартал', 75, 0);
const donutYear = createDonut(pctYear, 'Год', 0, 0);
// Логика брейкпоинтов для адаптивности
const w = window.innerWidth;
// Уменьшаем порог до 750px (между 3-в-ряд ~640px и 2-в-ряд ~960px)
const isMobile = w < 750;
// Шрифты для заголовка
const fsMain = isMobile ? 17 : 22;
const fsUnit = isMobile ? 13 : 16;
// Шрифт оси Y (План/Факт) - увеличен до 16
const fsAxis = 16;
return {
backgroundColor:'transparent',
title: {
text: TITLE_RICH,
left: 'center',
top: 6,
textStyle: {
fontFamily: 'Montserrat',
rich: {
main: {
color: '#272727',
fontSize: fsMain,
fontWeight: 'normal'
},
unit: {
color: '#BFBFBF',
fontSize: fsUnit,
fontWeight: 'normal'
}
}
}
},
grid:{ left:16, right:120, top:42, bottom:12, containLabel:true },
tooltip:{
trigger:'item',
position: tooltipRightPosition,
textStyle: { fontFamily: 'Montserrat' },
formatter:(p)=>{
const isFact = String(p.name).startsWith('Факт');
if(p.seriesType==='bar' && isFact) {
const d = deltaMoM || 0;
const sign = d > 0 ? '+' : '';
const deltaTxt = d !== 0 ? ` (${sign}${nfFact(d)} прирост на начало текущего месяца)` : '';
return `Факт: <b>${nfFact(p.value)}</b>${deltaTxt}<br/>В том числе Импорт: <b>${nfFact(importCum)}</b>`;
}
return `${p.name}: <b>${nfPlan(p.value)}</b>`;
}
},
xAxis:{ type:'value', show:false },
yAxis:{
type:'category', inverse:true, data:labels,
axisLine:{show:false}, axisTick:{show:false},
axisLabel:{
color:'#555', fontSize:fsAxis, fontWeight:600, margin:12,
fontFamily: 'Montserrat'
}
},
series:[
{
type:'bar',
barWidth:36,
barCategoryGap:'45%',
itemStyle:{ borderRadius:[6,6,6,6], shadowBlur:6, shadowColor:'rgba(0,0,0,0.06)' },
data: values.map((v,i)=>({
value:v,
itemStyle:{
color: new echarts.graphic.LinearGradient(0,0,1,0,[
{ offset:0, color: colors[i%colors.length].c1 },
{ offset:1, color: colors[i%colors.length].c2 }
])
}
})),
label:{
show:true,
position:'right',
distance:8,
fontSize:20,
fontWeight:'700',
fontFamily: 'Montserrat',
formatter:p=>{
if (p.name === 'Факт') {
const d = deltaMoM || 0;
if (d !== 0) {
const sign = d > 0 ? '+' : '';
return `${nfFact(p.value)} {delta|(${sign}${nfFact(d)})}`;
}
return nfFact(p.value);
}
return nfPlan(p.value);
},
rich: {
delta: {
fontSize: 14,
color: '#9CA3AF',
fontWeight: '500',
fontFamily: 'Montserrat'
}
}
}
}
],
graphic:[donutQuarter, donutYear],
animationDuration:350,
animationEasing:'cubicOut'
};
}
function ensureChart(el){ return echarts.getInstanceByDom(el) || echarts.init(el,null,{renderer:'svg'}); }
function createRow(title, isGroup=false){
const row=document.createElement('div');
row.className='row';
const aside=document.createElement('div');
aside.className='row-aside';
const h=document.createElement('div');
h.className='row-title' + (isGroup?' group':'');
h.textContent=title || '';
aside.appendChild(h);
const main=document.createElement('div');
main.className='row-main';
const card=document.createElement('div');
card.className='card';
const chart=document.createElement('div');
chart.className='chart';
card.appendChild(chart);
main.appendChild(card);
row.appendChild(aside);
row.appendChild(main);
return { row, chartEl: chart };
}
function renderAll(rows){
// 1. Очищаем старые инстансы
charts.forEach(ch => {
if (!ch.isDisposed()) {
ch.dispose();
}
});
charts = [];
const grid=document.getElementById('grid');
grid.innerHTML='';
const ORDER = ['ЭКСАР','РЭБ'];
ORDER.forEach(src=>{
const mAgg = buildMonthlyAgg(rows, src);
const r = buildBars(mAgg, src);
const { row, chartEl } = createRow(src, false);
grid.appendChild(row);
const chart=ensureChart(chartEl);
chart.setOption(buildOption({
labels:r.labels,
values:r.values,
deltaMoM:r.deltaMoM,
pctYear:r.pctYear,
pctQuarter: r.pctQuarter,
importCum:r.importCum
}), true);
charts.push(chart);
});
// Группа РЭЦ
{
const groupAgg = buildMonthlyAggGeneric(rows, 'sev_group_mlrd');
const r = buildBars(groupAgg, "Группа РЭЦ");
const { row, chartEl } = createRow('Группа РЭЦ', true);
grid.appendChild(row);
const chart=ensureChart(chartEl);
chart.setOption(buildOption({
labels:r.labels,
values:r.values,
deltaMoM:r.deltaMoM,
pctYear:r.pctYear,
pctQuarter: r.pctQuarter,
importCum:r.importCum
}), true);
charts.push(chart);
}
}
// 3. Добавляем слушатель ресайза с debounce
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const rows = getRowsFromContainer();
renderAll(rows);
}, 100);
});
window.renderPolymaticaCharts = function(payload){
let rows=[];
if(Array.isArray(payload)) rows=payload;
else if(payload?.data?.rows) rows=payload.data.rows;
else rows=getRowsFromContainer();
renderAll(rows);
};
renderAll(getRowsFromContainer());
})();
</script>
</body>
</html>
Read 34 times, last 41 seconds ago
MicroBin by Dániel Szabó and the FOSS Community. Hosted by @sqkrv.