d c. js график серии - график слишком медленный при заполнении отсутствующих данных - PullRequest
1 голос
/ 09 марта 2020

Я хотел бы создать диаграмму с несколькими временными линиями как диаграмму серии.

Я прочитал переполнение стека относительно заполнения пропущенных данных d c. js lineChart - заполнить пропущенные даты и показать ноль, где нет данных

Вопрос: Я реализовал код там, и он отлично работал для однолинейного графика. Для графика серии мне нужно было немного его настроить. Это работает, однако производительность ужасна.

Вот пример данных, которые мы используем:

let data = [{description: "Walmart", location: "40.216403 -74.541296", timeReported: 1581710670184}
 {description: "Target", location: "38.271996 -84.032575", timeReported: 1583524065011}
 {description: "Wendys", location: "39.255831 -75.532763", timeReported: 1583524065011}
 {description: "7-11", location: "34.925349 -78.463977", timeReported: 1583524065011}
 {description: "WaWa", location: "35.716208 -77.741230", timeReported: 1583524065013}
 {description: "7-11", location: "41.258950 -83.888060", timeReported: 1583524065013}
 {description: "Shell", location: "37.879694 -79.836127", timeReported: 1583524065011}
 {description: "Dominos", location: "35.890273 -80.700329", timeReported: 1583524065395}
 {description: "Dominos", location: "39.268777 -78.743366", timeReported: 1583524065397}
 {description: "Walgreens", location: "35.490215 -81.773863", timeReported: 1583524065399}
 {description: "7-11", location: "37.974797 -81.393449", timeReported: 1583524065506}
 {description: "Wendys", location: "40.859685 -76.963065", timeReported: 1583524065521}
 {description: "CVS", location: "38.517910 -78.251419", timeReported: 1583524065553}
 {description: "CVS", location: "35.947033 -81.616061", timeReported: 1583524142169}
 {description: "Shell", location: "39.566535 -77.992499", timeReported: 1583524142176}
 {description: "Target", location: "37.832142 -88.003151", timeReported: 1583524142170}
 {description: "Wendys", location: "40.245397 -80.061998", timeReported: 1583524142223}
 {description: "Macys", location: "39.631265 -75.157194", timeReported: 1583524142223}
 {description: "Macys", location: "36.631458 -77.803286", timeReported: 1583524142213}
 {description: "7-11", location: "36.249754 -79.830006", timeReported: 1583524142251}
 {description: "7-11", location: "41.138285 -83.298142", timeReported: 1583524142249}
 {description: "Wendys", location: "34.940485 -77.230388", timeReported: 1583524142249}
 {description: "7-11", location: "39.605373 -77.448768", timeReported: 1583524142296}
 {description: "Wendys", location: "35.609094 -79.455712", timeReported: 1583524142293}
 {description: "WaWa", location: "37.130753 -78.076709", timeReported: 1583524142310}
 {description: "Macys", location: "40.058482 -78.497258", timeReported: 1583524142338}
 {description: "Wendys", location: "39.255831 -75.532763", timeReported: 1582058735883}
 {description: "Macys", location: "39.631265 -75.157194", timeReported: 1582058735883}
 {description: "7-11", location: "36.249754 -79.830006", timeReported: 1582058735883}
 {description: "7-11", location: "39.605373 -77.448768", timeReported: 1582058735883}
 {description: "Wendys", location: "35.609094 -79.455712", timeReported: 1582058735883}
 {description: "WaWa", location: "37.130753 -78.076709", timeReported: 1582058735883}
 {description: "Macys", location: "40.058482 -78.497258", timeReported: 1582058735883}
 {description: "Kohls", location: "40.373533 -101.057470", timeReported: 1582838559493}] 

Вот пример кода. Кстати, curTimeInterval в приведенном ниже коде является просто псевдонимом для d3 timeIntervlas, который может быть выбран пользователем. (d3.timeHour, d3.timeDay, d3.timeWeek, d3.timeMonth).

cf = crossfilter(data);

dateDim = cf.dimension((d) => {
  return curTimeInterval(d.timeReportedDate);
});
reportedGroup = dateDim.group().reduceSum((d) => 1);


let minDate = d3.min(reportedGroup.all(), (kv) => {
  return kv.key;
});
let maxDate = d3.max(reportedGroup.all(), (kv) => {
  return kv.key;
});
minDate = curTimeInterval.offset(minDate, -2);
maxDate = curTimeInterval.offset(maxDate, 2);

const runDimension = cf.dimension((d) => {
  return [d.description, curTimeInterval(d.timeReportedDate)];
});


const runGroup = runDimension.group();

// Fills the missing data in the group
const filledSeries = fill_composite_intervals(runGroup, curTimeInterval);

const seriesChart = new dc.SeriesChart('#series');
seriesChart
  .width(768)
  .height(480)
  .chart(function(c) {
    return new dc.LineChart(c).curve(d3.curveCardinal);
  })
  .x(d3.scaleTime().domain([minDate, maxDate]))
  .xUnits(curTimeInterval.range)
  .brushOn(false)
  .clipPadding(10)
  .elasticY(true)
  .dimension(runDimension)
  .group(filledSeries)
  .mouseZoomable(true)
  .seriesAccessor((d) => {
    return d.key[0];
  })
  .keyAccessor((d) => {
    return d.key[1];
  })
  .valueAccessor((d) => {
    return d.value;
  })
  .legend(dc.legend().x(350).y(350).itemHeight(13).gap(5).horizontal(1).legendWidth(140).itemWidth(70))
  .yAxis()
  .tickValues(d3.range(min > 0 ? min - 1 : min, max + 1));

seriesChart.margins().left += 40;


fill_composite_intervals = (group, interval) => {
  return {
    all: function() {
      const retVal = [];
      const allArray = group.all();
      if (!allArray.length) {
        return retVal;
      }
      allArray.sort((a, b) => {
        if (a.key[1].getTime() < b.key[1].getTime()) {
          return -1;
        }
        if (a.key[1].getTime() > b.key[1].getTime()) {
          return 1;
        }
        // a must be equal to b
        return 0;
      });
      const target = interval.range(allArray[0].key[1], allArray[allArray.length-1].key[1]);
      const allMap = new Map();
      allArray.forEach((obj) => {
        let innerArray = allMap.get(obj.key[0]);
        if (!innerArray) {
          innerArray = [];
          allMap.set(obj.key[0], innerArray);
        }
        innerArray.push({key: obj.key[1], value: obj.value});
      });
      allMap.forEach((value, key, map) => {
        const orig = value.map((kv) => ({key: new Date(kv.key), value: kv.value}));

        const result = [];
        if (orig.length) {

          let oi;
          let ti;
          for (oi = 0, ti = 0; oi < orig.length && ti < target.length;) {
            if (orig[oi].key <= target[ti]) {
              result.push(orig[oi]);
             if (orig[oi++].key.valueOf() === target[ti].valueOf()) {
                ++ti;
              }
            } else {
              result.push({key: target[ti], value: 0});
              ++ti;
            }
          }
          if (oi<orig.length) {
            Array.prototype.push.apply(result, orig.slice(oi));
          }
          if (ti<target.length) {
            Array.prototype.push.apply(result, target.slice(ti).map((t) => ({key: t, value: 0})));
          }
        }
        map.set(key, result);
      });

      allMap.forEach((value, key, map) => {
        value.forEach((obj) => {
          const newObj = {
            key: [key, obj.key],
            value: obj.value
          };

          retVal.push(newObj);
        });
      });
            return retVal;
    }
  };
};

Ответы [ 2 ]

1 голос
/ 21 марта 2020

Поскольку мой предыдущий ответ был все еще слишком медленным при использовании с небольшими временными интервалами, я переписал ядро ​​l oop.

Вместо того, чтобы пройти весь диапазон дат между началом и концом, это гораздо быстрее и намного проще просто посмотреть на данные и определить, нужно ли добавить один или два нуля между последней точкой данных и этой.

Сердце fill_composite_intervals теперь выглядит как

  const [begin, end] = d3.extent(allArray, ({key}) => key[1]).map(interval);

  // walk each category, adding leading and trailing zeros
  allMap.forEach((value, key, map) => {
    const orig = value.map(({key, value}) => ({key: new Date(key), value}));

    const result = [];
    if (orig.length) {
      let last = interval.offset(begin, -2);
      for(let oi = 0; oi < orig.length; ++oi) {
        const count = interval.count(last, orig[oi].key);
        if(count === 0 || count === 1) ;
        else {
          result.push({key: interval.offset(last, 1), value: 0});
          if(count > 2)
            result.push({key: interval.offset(orig[oi].key, -1), value: 0});
        }
        result.push(orig[oi]);
        last = orig[oi].key;
      }
      result.push({key: interval.offset(orig[orig.length-1].key, 1), value: 0});
    }
    map.set(key, result);
  });

Ускоренная скрипка .

Обновление: более плавное, симметричное c кривые

Первая и последняя кривые деформированы, поскольку в них отсутствует контрольная точка на сплайн, чтобы сделать наклон 0 по краям.

Мы можем добавить еще один ноль в начале и в конце.

Вот быстрая и плавная группа подделок для графиков с несколькими временными линиями.

function fill_composite_intervals(group, interval) {
  return {
    all: function() {
      const retVal = [];
      const allArray = group.all().slice();
      if (!allArray.length) {
        return retVal;
      }
      // make sure input data is sorted
      allArray.sort((a, b) => a.key[1].getTime() - b.key[1].getTime());

      // separate the data for each category
      const allMap = new Map();
      allArray.forEach(({key: [cat, time], value}) => {
        let innerArray = allMap.get(cat);
        if (!innerArray) {
          innerArray = [];
          allMap.set(cat, innerArray);
        }
        innerArray.push({key: time, value});
      });


      // walk each category, adding leading and trailing zeros
      allMap.forEach((value, key, map) => {
        const orig = value.map(({key, value}) => ({key: new Date(key), value}));

        const result = [];
        if (orig.length) {
          let last = interval.offset(orig[0].key, -3);
          for(let oi = 0; oi < orig.length; ++oi) {
            const count = interval.count(last, orig[oi].key);
            if(count === 0 || count === 1) ;
            else {
              result.push({key: interval.offset(last, 1), value: 0});
              if(count > 2)
                result.push({key: interval.offset(orig[oi].key, -1), value: 0});
            }
            result.push(orig[oi]);
            last = orig[oi].key;
          }
          result.push(
            {key: interval.offset(orig[orig.length-1].key, 1), value: 0},
            {key: interval.offset(orig[orig.length-1].key, 2), value: 0},
          );
        }
        map.set(key, result);
      });

      allMap.forEach((value, key, map) => {
        value.forEach(({key: time, value}) => {
          retVal.push({
            key: [key, time],
            value
          });
        });
      });
      return retVal;
    }
  };
}

smoother, symmetric curves Более гладкая скрипка .

1 голос
/ 15 марта 2020

Я начал с создания скрипки , которая иллюстрирует проблему. Интересно, что здесь есть меню выбора, которое показывает, какие временные интервалы соответствуют данным и уровню масштабирования (области) диаграммы.

Неверно показывать больше, чем ширина / 2 точки (так как они не будет отображаться), и также нецелесообразно показывать менее двух точек, поэтому «неподходящими» параметрами являются серые италийские c:

fiddle illustrating appropriate/inappropriate intervals

Он использует имена интервалов сопоставления объектов с количеством миллисекунд в соответствующем интервале d3:

const intervals = {
  timeSecond: 1000,
  timeMinute: 60000,
  timeHour: 3600000,
  timeDay: 86400000,
  timeWeek: 604800000,
  timeMonth: 2628000000,
  timeYear: 31536000000
}

allowed_intervals определяет первый и последний соответствующий интервал:

function allowed_intervals(chart, intervals, dateDomain) {
  const dt = dateDomain[1].getTime() - dateDomain[0].getTime(),
    first = Object.entries(intervals).find(
        ([iname, ms]) => dt / ms < chart.width() / 2);
  if(!first)
    throw new Error('date range too long')
  const last = Object.entries(intervals).reverse().find(
     ([iname, ms]) => d3[iname](dateDomain[0]).getTime() !== d3[iname](dateDomain[1]).getTime());
  return [first[0],last[0]];
}

Так что все хорошо. В примере печатаются результирующие данные, и мы можем видеть, что если мы заполним данные примера d3.timeMinute, то получим 332482 точки данных из исходных 15. Это явно слишком много данных, особенно для простого примера.

Это хороший алгоритм для поиска подходящего временного интервала d3. Однако происходит сбой, когда мы включаем масштабирование, потому что теперь мы можем увеличить масштаб за один час, скажем, где timeMinute подходит, но если вы используете этот интервал для всех данных, это слишком много точек, и диаграмма замедляется до остановка.

Поэтому я начал думать о том, как сделать его более эффективным. Нам не нужно заполнять каждый пропущенный интервал времени. Нам действительно нужно убедиться, что мы поймали передний фронт , когда данные переходят от ненулевого к нулю, и передний фронт , где данные идут от нуля до ненулевого , В этих случаях нам нужно только добавить нули к входным данным.

Вот новая версия fill_composite_intervals, которая использует нарастающие и падающие края, добавляя только столько нулей, сколько необходимо для отображения этих краев:

// input: a group with keys [category, time] and numeric values; a d3 time interval
// output: the same, but with zeroes filled in per the interval
function fill_composite_intervals(group, interval) {
  return {
    all: function() {
      const retVal = [];
      const allArray = group.all().slice();
      if (!allArray.length) {
        return retVal;
      }
      // make sure input data is sorted
      allArray.sort((a, b) => a.key[1].getTime() - b.key[1].getTime());

      // find all time intervals within the data
      // pad at both ends to add leading and trailing zeros
      const target = interval.range(interval.offset(allArray[0].key[1], -1),
        interval.offset(allArray[allArray.length-1].key[1], 2));

      // separate the data for each category
      const allMap = new Map();
      allArray.forEach(({key: [cat, time], value}) => {
        let innerArray = allMap.get(cat);
        if (!innerArray) {
          innerArray = [];
          allMap.set(cat, innerArray);
        }
        innerArray.push({key: time, value});
      });

      // walk each category, adding leading and trailing zeros
      allMap.forEach((value, key, map) => {
        const orig = value.map(({key, value}) => ({key: new Date(key), value}));

        const result = [];
        if (orig.length) {
          let oi = 0, ti = 0, last_filled = false, skipped_fill = false;
          while(oi < orig.length && ti < target.length) {
            if (orig[oi].key <= target[ti]) {
              if(skipped_fill) {
                // in the last iteration, we skipped a zero
                // so add one now (rising edge)
                result.push({key: target[ti-1], value: 0});
                skipped_fill = false;
              }
              result.push(orig[oi]);
              if (orig[oi++].key.getTime() === target[ti].getTime()) {
                ++ti;
              }
              last_filled = false;
            } else {
              if(!last_filled) {
                // last iteration we pushed a value
                // so push a zero now (falling edge)
                result.push({key: target[ti], value: 0});
                last_filled = true;
              }
              else skipped_fill = true;
              ++ti;
            }
          }
          if (oi<orig.length) {
            Array.prototype.push.apply(result, orig.slice(oi));
          }
          if (ti<target.length) {
            // add one trailing zero at the end
            result.push({key: target[ti], value: 0});
          }
        }
        map.set(key, result);
      });

      allMap.forEach((value, key, map) => {
        value.forEach(({key: time, value}) => {
          retVal.push({
            key: [key, time],
            value
          });
        });
      });
      return retVal;
    }
  };
}

См. Комментарии в код для объяснения. Он производит только данные, пропорциональные входным данным, например, 67 точек для ввода 15 с timeMinute вместо 300 + K.

Интересно, что я обнаружил, что d3.curveCardinal создает странные артефакты, когда меньше нулей , Интуитивно я думаю, что линия набирает слишком много «импульса», если пропустить точки. Поэтому я выбрал d3.curveMonotoneX . Я думаю, что в любом случае это более уместно.

  .curve(d3.curveMonotoneX)

Я также добавил interval.range в начале и в конце, чтобы данные начинались и заканчивались на нуле, что более привлекательно.

Этот пример все еще медленный, когда вы выбираете d3.timeSecond (он все еще проходит через 300 + K точек), но, кажется, он работает нормально до timeMinute, что, кажется, отражает разрешение этих данных.

Дальнейшие возможные улучшения:

  1. добавьте больше начальных и конечных нулей, чтобы кривые были согласованными / симметричными c
  2. , прекратите использование interval.range, чтобы вычислять не так много точек и выброшенный; вместо этого обнаруживайте нарастающие и падающие фронты, используя interval.offset и только следующие / последние точки данных (хитро!)

Пример скрипта . screenshot - more efficient; leading/trailing edges

...