Raw Text Content QR Edit Remove
worm



<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8" />
  <title>ОВС ($ млрд) · Группа и компании — компактные бары</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    body { margin:0; font-family: system-ui,-apple-system,Segoe UI,Roboto,Arial; background:#fafafa; }
    .grid { display:flex; flex-direction:column; gap:12px; padding:12px 8px; }

    .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:900; color:#2F454F; letter-spacing:.2px; line-height:1.2;
    }
    .row-title.group{ font-size:24px; font-weight:1000; 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>
  ;(() => {
    const TITLE_EACH = 'Объем внешнеторговых сделок ($ млрд)';

    // Форматтер для Факта (строгий: 1 или 3 знака после запятой)
    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}, // в 4кв план квартала = план года
        {type:'fcst',label:'Прогноз 12М',months:12}
      ];
    }

    function getRowsFromContainer(){
      const el = document.querySelector('#data');
      let raw = (el?.textContent || '').trim();
      if (!raw) return [];
      if(raw.includes('&quot;')) raw = raw.replace(/&quot;/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];
    }

    // Вспомогательная функция для создания кружка (БЕЗ подписи снизу)
    // Добавили rightOffset для управления отступом справа
    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: '800',
              textAlign: 'center',
              textVerticalAlign: 'middle'
            }
          }
        ]
      };
    }

    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'}  // Прогноз
      ];

      // Создаем два пончика: один для квартала (повыше), один для года (пониже)
      // Прижимаем вправо (right: 0) и нижний в угол (bottom: 0). 
      // Между ними делаем зазор (нижний 0, верхний 75)
      const donutQuarter = createDonut(pctQuarter, 'Квартал', 75, 0);
      const donutYear = createDonut(pctYear, 'Год', 0, 0);

      return {
        backgroundColor:'transparent',
        title:{
          text: TITLE_EACH,
          left:'center',
          top:6,
          textStyle:{ fontSize:16, fontWeight:800, color:'#2F454F', lineHeight:18 }
        },
        // Увеличиваем отступ справа (right), чтобы влезли два пончика
        grid:{ left:16, right:120, top:32, bottom:12, containLabel:true },

        tooltip:{
          trigger:'item',
          position: tooltipRightPosition,
          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:'#111', fontSize:18, fontWeight:700, margin:12 }
        },

        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:'900',
              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'
                }
              }
            }
          }
        ],

        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){
      const grid=document.getElementById('grid');
      grid.innerHTML='';
      const charts=[];

      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);
      }

      window.addEventListener('resize',()=>charts.forEach(ch=>ch.resize()));
    }

    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 5 times, last 7 hours ago


MicroBin by Dániel Szabó and the FOSS Community. Hosted by @sqkrv.