amcharts / amcharts4

The most advanced amCharts charting library for JavaScript and TypeScript apps.
https://www.amcharts.com/
1.16k stars 321 forks source link

Bullet and line bug in 4.6.2 #1693

Closed hugoperier closed 4 years ago

hugoperier commented 4 years ago

Hello, its me again

I've seen you had release the 4.6.2, removing the bullet bug, but it dont work for me I have the same bug again after update the library

Here is a screen reminder image

But now the bullet follow the line (but they are badly placed and there is no relationship to the data)

martynasma commented 4 years ago

Can you please post this chart as a codepen or jsfiddle?

hugoperier commented 4 years ago

Like the last time I dont know how to make a runnable example with my big component react, but the bug occurs when we remove the last curve added

The bug is available on https://beta.dt-price.com (4.6.0 but it stay the same bug, and I can update to the 4.6.2 if you want)

And here is my code

import React, { Component, Fragment } from "react";
import * as am4core from "@amcharts/amcharts4/core";
import * as algo from "./Algorithm";
import * as am4charts from "@amcharts/amcharts4/charts";
import am4themes_animated from "@amcharts/amcharts4/themes/animated";
import List from "./List";
import Searchbar from "./Searchbar";
import { fetchPrices } from "../services/api";
import am4lang_fr_FR from "@amcharts/amcharts4/lang/fr_FR";

import "./Chart.css";

am4core.useTheme(am4themes_animated);

const dayIntervall = 30

class NewChart extends Component {
  /*
    colorList contains the list of the color that will be used for the list and the graph
    list is used to contain all data about items (prices, id, name, imgUrl), list is unique for a server and is overwritten on change
    serieList contains all series on the chart, at the exact index 
    range is the checkbox state, for selecting an item price by 1, 10 or 100    
  */
  constructor(props) {
    super(props);
    this.state = {
      colorList: [
        { color: "#4286f4", available: true },
        { color: "#41f441", available: true },
        { color: "#e8f441", available: true },
        { color: "#ef0000", available: true },
        { color: "#b700ef", available: true },
        { color: "#1b00ef", available: true },
        { color: "#004701", available: true },
        { color: "#ff00b6", available: true }
      ],
      itemsPrices: [],
      list: [],
      server: 2,
      seriesList: [],
      maxItemSelected: 8,
      displayUnitPrice: false,
      onlyLatestData: true,
      ranges: { 1: true, 10: false, 100: false }
    };

    this.onOptionTimeStampClicked = this.onOptionTimeStampClicked.bind(this);
  }

  UNSAFE_componentWillReceiveProps(nextProps) {
    const items = nextProps.AlgoParam.items;
    if (nextProps.serverId !== this.props.serverId) {
      this.setState({ server: nextProps.serverId });
      this.updateItemsPriceByServer();
    }
    if (this.props.AlgoParam && this.props.AlgoParam.items !== items) {
      this.onPropsAlgoChanged(items, this.props.AlgoParam.items);
    }
  }

  onPropsAlgoChanged = (newItems, oldItems) => {
    const { seriesList, list, onlyLatestData } = this.state;

    for (let i = 0; i < newItems.length; i++) {
      if (newItems[i].isActive === true && oldItems[i].isActive === false) {
        const seriesListCopy = JSON.parse(JSON.stringify(seriesList));
        const listCopy = JSON.parse(JSON.stringify(list));
        const splines = algo[newItems[i].code](
          seriesListCopy,
          listCopy,
          onlyLatestData,
          newItems[i]
        );
        if (splines) this.addAlgoSeries(splines);
      } else if (
        newItems[i].isActive === false &&
        oldItems[i].isActive === true
      ) {
        this.removeAlgoSerie(newItems[i].code);
      }
    }
  };

  updateAlgo = () => {
    var { seriesList, chart, onlyLatestData, list } = this.state;
    const algos = this.props.AlgoParam.items;

    for (let i = 0; i < seriesList.length; i++) {
      if (seriesList[i].type === "algo") {
        chart.series.removeIndex(i);
        seriesList.splice(i, 1);
        i = 0;
      }
    }
    this.setState({ chart, seriesList });
    if (list.length === 0) return;
    for (let i = 0; i < algos.length; i++) {
      if (algos[i].isActive === true) {
        const seriesListCopy = JSON.parse(JSON.stringify(seriesList));
        const listCopy = JSON.parse(JSON.stringify(list));
        const splines = algo[algos[i].code](
          seriesListCopy,
          listCopy,
          onlyLatestData,
          algos[i]
        );
        this.addAlgoSeries(splines);
      }
    }
  };

  removeAlgoSerie = label => {
    const { seriesList, chart } = this.state;
    for (let i = 0; i < seriesList.length; i++) {
      if (seriesList[i].algo === label) {
        chart.series.removeIndex(i);
        seriesList.splice(i, 1);
        console.log("removing serie at index " + i)
        i = 0;
      }
    }
    if (label === "minimum" || label === "maximum") this.removeEvent(label);
    this.setState({ chart, seriesList });
  };

  removeEvent = label => {
    const { chart } = this.state;

    if (!chart) return

    let dateAxis = chart.xAxes.getIndex(0);
    for (let i = 0; i < dateAxis.axisRanges.length; i++) {
      let event = dateAxis.axisRanges.getIndex(i);
      if (label === "all") {
        dateAxis.axisRanges.removeIndex(i);
        i = -1;
      } else if (event.algo === label) {
        dateAxis.axisRanges.removeIndex(i);
        i = -1;
      }
    }
  };

  addMultiAlgoSplines = spline => {
    const { chart, seriesList } = this.state;

    let seriestop = chart.series.push(new am4charts.LineSeries());
    let seriesmid = chart.series.push(new am4charts.LineSeries());
    let seriesbot = chart.series.push(new am4charts.LineSeries());
    let gap = chart.series.push(new am4charts.LineSeries());

    seriestop.data = spline.data;
    seriestop.dataFields.dateX = "date";
    seriestop.dataFields.valueY = "upper";

    gap.data = spline.data;
    gap.dataFields.dateX = "date";
    gap.dataFields.openValueY = "upper";
    gap.dataFields.valueY = "lower";
    gap.fillOpacity = 0.3;

    seriesmid.data = spline.data;
    seriesmid.dataFields.dateX = "date";
    seriesmid.dataFields.valueY = "mid";

    seriesbot.data = spline.data;
    seriesbot.dataFields.dateX = "date";
    seriesbot.dataFields.valueY = "lower";

    seriesbot.stroke = am4core.color(spline.color);
    seriestop.stroke = am4core.color(spline.color);
    seriesmid.stroke = am4core.color(spline.color);
    gap.stroke = am4core.color(spline.color);

    gap.algo = true;
    seriesbot.algo = true;
    seriestop.algo = true;
    seriesmid.algo = true;

    const serie = {
      code: -1,
      label: spline.label,
      type: "algo",
      algo: spline.algo
    };
    seriesList.push(serie);
    seriesList.push(serie);
    seriesList.push(serie);
    seriesList.push(serie);
    this.setState({ seriesList, chart });
  };

  addBulletAlgo = spline => {
    const { chart } = this.state;

    let dateAxis = chart.xAxes.getIndex(0);
    let event = dateAxis.axisRanges.create();
    event.algo = spline.algo;
    event.date = new Date(spline.date);
    event.grid.disabled = true;
    event.bullet = new am4core.Triangle();
    event.bullet.horizontalCenter = "middle";
    if (spline.algo === "minimum") event.bullet.rotation = 180;
    event.bullet.paddingRight = 15;
    event.bullet.width = 15;
    event.bullet.height = 11;
    event.bullet.fill = am4core.color(spline.color);
    this.setState({ chart });
  };

  addAlgoSeries = splines => {
    let { chart, seriesList } = this.state;

    console.log("add serie")
    for (let i = 0; i < splines.length; i++) {
      if (splines[i].algo === "bollinger") {
        this.addMultiAlgoSplines(splines[i]);
      } else if (
        splines[i].algo === "maximum" ||
        splines[i].algo === "minimum"
      ) {
        this.addBulletAlgo(splines[i]);
      } else {
        let series = chart.series.push(new am4charts.LineSeries());
        series.dataFields.dateX = "date";
        series.dataFields.valueY = "value";
        series.stroke = am4core.color(splines[i].color);
        series.algo = true;
        series.data = splines[i].data;
        console.log(series)
        chart.scrollbarX.series.push(series);
        const serie = {
          code: -1,
          label: splines[i].label,
          type: "algo",
          algo: splines[i].algo
        };
        seriesList.push(serie);
      }
    }
    this.setState({ chart, seriesList });
  };

  updateItemsPriceByServer = async () => {
    const { chart, list, colorList, seriesList } = this.state;
    const cpyList = [...list];

    for (let j = seriesList.length - 1; j >= 0; j--) {
      chart.series.removeIndex(j);
      seriesList.splice(j, 1);
    }
    colorList.map(color => (color.available = true));
    seriesList.length = 0;
    list.length = 0;

    await this.setState({ colorList, list, seriesList, chart });
    for (var i = 0; i < cpyList.length; i++) {
      await this.addItemPrices(cpyList[i].code);
    }
  };

  getAvailableColor = () => {
    let { colorList } = this.state;

    for (var i = 0; i < colorList.length; i++) {
      if (colorList[i].available === true) {
        colorList[i].available = false;
        this.setState({ colorList });
        return colorList[i].color;
      }
    }
  };

  changeCheckbox = range => () => {
    const { ranges, list } = this.state;
    const newRanges = { ...ranges, [range]: !ranges[range] };
    const typeRanges = { 1: "unite", 10: "dizaine", 100: "centaine" };

    for (let i = 1; i <= 100; i *= 10) {
      if (ranges[i] === false && newRanges[i] === true) {
        for (let l = 0; l < list.length; l++) {
          this.addSeries(list[l], typeRanges[i], list[l].color);
        }
        this.updateAlgo();
      } else if (ranges[i] === true && newRanges[i] === false) {
        const { chart, seriesList } = this.state;
        for (let q = 0; q < seriesList.length; q++) {
          if (seriesList[q].type === typeRanges[i]) {
            chart.series.removeIndex(q);
            seriesList.splice(q, 1);
            q--;
          }
        }
        this.setState({ chart, seriesList }, this.updateAlgo());
      }
    }
    this.setState({ ranges: newRanges });
  };

  componentWillUnmount() {
    if (this.chart) {
      this.chart.dispose();
    }
  }

  removeItem = e => {
    const { chart, seriesList, list, colorList } = this.state;

    const listElement = list.filter(elem => elem.code === e.code);
    var colorToFree = listElement[0].color;

    for (let i = 0; i < list.length; i++) {
      if (list[i].code === e.code) {
        list.splice(i, 1);
        break;
      }
    }
    for (let i = 0; i < seriesList.length; i++) {
      if (seriesList[i].code === e.code) {
        chart.series.removeIndex(i);
        console.log("removing series at index " + i + ", logging chart info")
        seriesList.splice(i, 1);
      }
    }
    console.log(chart)
    if (list.length === 0) {
      chart.dispose();
    }
    const colorListUpdated = colorList.map(item => {
      return item.color === colorToFree ? { ...item, available: true } : item;
    });
    this.updateAlgo();
    this.setState({ chart, seriesList, list, colorList: colorListUpdated });
  };

  onOptionTimeStampClicked = e => {
    let { onlyLatestData, chart, list, seriesList } = this.state;

    if (onlyLatestData === true && e.target.checked === false) {
      for (let i = 0; i < seriesList.length; i++) {
        let p = chart.series.getIndex(i);
        const tmp = list.find(elem => elem.code === seriesList[i].code);
        if (seriesList[i].type === "unite") {
          p.data = tmp.unit;
        } else if (seriesList[i].type === "dizaine") {
          p.data = tmp.decade;
        } else if (seriesList[i].type === "centaine") {
          p.data = tmp.hundred;
        }
      }
    } else if (onlyLatestData === false && e.target.checked === true) {
      for (let i = 0; i < chart.series.length; i++) {
        let p = chart.series.getIndex(i);
        p.data = p.data.filter(data => {
          return (new Date() - data.date) / (1000 * 3600 * 24) <= dayIntervall;
        });
      }
    }
    this.setState({ onlyLatestData: e.target.checked }, this.updateAlgo);
  };

  onUnitCheckboxClicked = e => {
    const { displayUnitPrice, chart, seriesList, list } = this.state;
    const divideDictionnary = {
      unite: 1,
      dizaine: 10,
      centaine: 100
    };

    if (displayUnitPrice === true && e.target.checked === false) {
      for (let i = 0; i < seriesList.length; i++) {
        let p = chart.series.getIndex(i);
        const tmp = list.find(elem => elem.code === seriesList[i].code);
        if (seriesList[i].type === "unite") {
          p.data = tmp.unit;
        } else if (seriesList[i].type === "dizaine") {
          p.data = tmp.decade;
        } else if (seriesList[i].type === "centaine") {
          p.data = tmp.hundred;
        }
      }
    } else if (displayUnitPrice === false && e.target.checked === true) {
      for (let i = 0; i < seriesList.length; i++) {
        let p = chart.series.getIndex(i);
        p.data = p.data.map(elem => {
          if (elem.value > 0) {
            return {
              date: elem.date,
              value: elem.value / divideDictionnary[seriesList[i].type]
            };
          } else {
            return { date: elem.date, value: 1 };
          }
        });
      }
    }
    this.updateAlgo();
    this.setState({ displayUnitPrice: e.target.checked });
  };

  RenderPrice = () => {
    const { ranges } = this.state;
    return (
      <Fragment>
        <div className="main-content-right_list">
          <List items={this.state.list} delete={this.removeItem} />

          <div className="square-container">
            <div className="main-content-right_range">
              <label className="main-content-right_range-item">
                Charger uniquement le dernier mois
                <input
                  type="checkbox"
                  onChange={this.onOptionTimeStampClicked}
                  defaultChecked={true}
                />
                <span className="checkmark" />
              </label>

              <label className="main-content-right_range-item">
                Afficher les prix à l'unité
                <input type="checkbox" onChange={this.onUnitCheckboxClicked} />
                <span className="checkmark" />
              </label>
            </div>

            <div className="main-content-right_range">
              <label className="main-content-right_range-item">
                1
                <input
                  type="checkbox"
                  onChange={this.changeCheckbox(1)}
                  checked={ranges[1]}
                />
                <span className="checkmark" />
              </label>
              <label className="main-content-right_range-item">
                10
                <input
                  type="checkbox"
                  onChange={this.changeCheckbox(10)}
                  checked={ranges[10]}
                />
                <span className="checkmark" />
              </label>
              <label className="main-content-right_range-item">
                100
                <input
                  type="checkbox"
                  onChange={this.changeCheckbox(100)}
                  checked={ranges[100]}
                />
                <span className="checkmark" />
              </label>
            </div>
          </div>
        </div>
      </Fragment>
    );
  };

  RenderText = () => {
    return (
      <div className="main-content-right-container">
        <div className="main-content-right-greeting">
          Bienvenue sur dt-price!
        </div>
        <img
          className="black-logo"
          src="https://file.dt-price.com/images/blacklogo.svg"
          alt="dark-logo"
        />
        <div className="main-content-right-tutorial">
          {"Dt-price est un site web permettant d'analyser et comparer "}
          {"les prix des différents items de Dofus Touch."}
          <p className="main-content-rigth-info">
            {"Nos données sont récoltées chaque heure, "}
            {"si des items ou plages horaires sont "}
            {"manquants, n'hésitez pas à nous le signaler sur notre serveur "}
            <a href={"https://discord.gg/NvruPar"}>discord</a>
            {" pour contribuer à l'amélioration du service!"}
          </p>
          <p className="main-content-right-begin-text">{"Pour commencer:"}</p>
          <ul>
            <li>{"Choisis ton serveur."}</li>
            <li>{"Sélectionne un ou plusieurs items dans la liste."}</li>
            <li>
              {
                "Tu peux sélectionner un algorithme de prévision en cliquant sur "
              }
              <img
                src="https://file.dt-price.com/images/foldbutton.svg"
                width="15px"
                height="15px"
                alt="settings-icon"
                style={{ transform: "rotate(180deg)" }}
              />
              {"."}
            </li>
            <li>
              {"Clique sur le bouton "}
              <img
                src="https://file.dt-price.com/images/info.svg"
                width="15px"
                height="15px"
                alt="info-icon"
              />
              {" pour comprendre comment utiliser l'algorithme."}
            </li>
            <li>
              {"Le bouton "}
              <img
                src="https://file.dt-price.com/images/setting.svg"
                width="15px"
                height="15px"
                alt="settings-icon"
              />
              {" permet de modifier les paramètres de l'algorithme "}
              {"(ils sont expliqués dans les informations)."}
            </li>
          </ul>
          <p className="main-content-rigth-info">
            {
              "dt-price est maintenu par des développeurs indépendants sans aucune affiliation à Ankama. Les données que nous diffusons à travers notre API ainsi que les images utilisées ne nous appartiennent donc pas. Nous ne sommes en aucun cas responsable de tout usage détourné de notre site ou API."
            }
          </p>
        </div>
      </div>
    );
  };

  getItemById = async (itemId, color, newServerId) => {
    const { list } = this.state;
    var item;

    if (newServerId === undefined) {
      item = list.find(e => {
        return e.code === itemId;
      });
    }
    if (item == null) {
      const itemsPrices = await fetchPrices(itemId, this.props.serverId);
      item = {
        unit: [],
        decade: [],
        hundred: []
      };
      for (let i = 0; i < itemsPrices.length; i++) {
        item.unit.push({
          date: new Date(itemsPrices[i].date),
          value: itemsPrices[i].unit
        });

        item.decade.push({
          date: new Date(itemsPrices[i].date),
          value: itemsPrices[i].decade
        });

        item.hundred.push({
          date: new Date(itemsPrices[i].date),
          value: itemsPrices[i].hundred
        });
      }
      item.label = itemsPrices[0].name;
      item.code = itemId;
      item.color = color;
      if (newServerId === undefined) {
        list.push(item);
        this.setState({ list });
      }
    }
    return item;
  };

  addItemPrices = async itemId => {
    const color = this.getAvailableColor();
    const { ranges } = this.state;
    var item = await this.getItemById(itemId, color);

    if (ranges[1] === true) {
      this.addSeries(item, "unite", color);
    }
    if (ranges[10] === true) {
      this.addSeries(item, "dizaine", color);
    }
    if (ranges[100] === true) {
      this.addSeries(item, "centaine", color);
    }
    this.updateAlgo();
  };

  addSeries = (item, type, color) => {
    let { chart, seriesList, onlyLatestData } = this.state;

    console.log("We add a serie")    
    let series = chart.series.push(new am4charts.LineSeries());
    series.dataFields.dateX = "date";
    series.dataFields.valueY = "value";

    if (type === "unite") {
      series.data = item.unit;
      series.bullets.push(new am4charts.CircleBullet());
    } else if (type === "dizaine") {
      series.data = item.decade;
      let bullet = series.bullets.push(new am4charts.Bullet());
      let square = bullet.createChild(am4core.Rectangle);
      square.width = 10;
      square.height = 10;
      square.horizontalCenter = "middle";
      square.verticalCenter = "middle";
    } else if (type === "centaine") {
      series.data = item.hundred;
      let bullet = series.bullets.push(new am4charts.Bullet());
      let arrow = bullet.createChild(am4core.Triangle);
      arrow.horizontalCenter = "middle";
      arrow.verticalCenter = "middle";
      arrow.stroke = am4core.color("#fff");
      arrow.direction = "top";
      arrow.width = 10;
      arrow.height = 10;
      bullet.createChild(am4core.Triangle);
    }
    /* Setting color to axis and tooltip */
    series.tooltip.getFillFromObject = false;
    series.tooltip.background.fill = am4core.color(color);
    series.tooltipText = item.label + " (" + type + ") : {valueY.value}";

    series.minBulletDistance = 15;
    series.stroke = am4core.color(color);
    console.log(series)
    chart.scrollbarX.series.push(series);

    if (onlyLatestData === true) {
      series.data = series.data.filter(data => {
        return (new Date() - data.date) / (1000 * 3600 * 24) <= dayIntervall; 
      });
    }
    const serie = {
      code: item.code,
      label: item.label,
      type: type
    };
    seriesList.push(serie);
    this.setState({ chart, seriesList });
  };

  addItem = async (value, obj) => {
    let { list, maxItemSelected } = this.state;
    if (list.length === 0) {
      console.log("creating chart")
      /* Creating chart and set language to french */
      let chart = am4core.create("chartdiv", am4charts.XYChart);
      chart.paddingRight = 20;
      chart.language.locale = am4lang_fr_FR;
      chart.fill = am4core.color("#fff");
      chart.stroke = am4core.color("#fff");
      /* Create date, values axis and cursor */
      let dateAxis = chart.xAxes.push(new am4charts.DateAxis());
      dateAxis.renderer.grid.template.location = 0;
      let valueAxis = chart.yAxes.push(new am4charts.ValueAxis());
      dateAxis.renderer.labels.template.fill = am4core.color("#fff");
      valueAxis.renderer.labels.template.fill = am4core.color("#fff");
      valueAxis.tooltip.disabled = true;
      valueAxis.renderer.minWidth = 35;
      chart.cursor = new am4charts.XYCursor();

      /* Setting up the scrollbar */
      let scrollbarX = new am4charts.XYChartScrollbar();
      chart.scrollbarX = scrollbarX;
      this.setState({ chart });
    }
    if (
      value !== "" &&
      list.length !== maxItemSelected &&
      !list.filter(e => e.code === obj.code).length > 0
    ) {
      await this.addItemPrices(obj.code);
      this.setState({ list });
      return true;
    }
    return false;
  };

  PriceRenderer = props => {
    const canRenderPrice = props.canRenderPrice;
    if (canRenderPrice) {
      return <this.RenderPrice />;
    }
    return <this.RenderText />;
  };

  render() {
    const { list } = this.state;
    const isListEmpty = list.length > 0;
    var divStyle;

    if (isListEmpty) {
      divStyle = {
        width: "100%",
        height: "500px"
      };
    }

    return (
      <Fragment>
        <Searchbar testaddItem={this.addItem} />
        <div id="chartdiv" style={divStyle} />
        <this.PriceRenderer canRenderPrice={isListEmpty} />
      </Fragment>
    );
  }
}

export default NewChart;
martynasma commented 4 years ago

I'm sorry, but I don't think we can use this.

Can you post it as a runnable/editable code somewhere?

If you can't post it on JS-only sites like codepen, you can post on codesandbox.io or stackblitz.

hugoperier commented 4 years ago

I cant post on stackblitz, I cant initialize my node_modules and it cant connect to my api....

An online version of the website with a lot of console.log can be acceptable ?

martynasma commented 4 years ago

I don't know. We can try that I suppose :)

hugoperier commented 4 years ago

Hello

I have updated the code above with some console log, and the website, you can see the log in the console of your browser.

The procedure to do for getting the bug is the following

-> Add an item via the searchbar (you can type sca and then click on an item) -> Add another item via the searchbar (different than the first)

Remove the last item added (very important, its the condition to make the bug appear)

After removing it, you will see the chart going crazy, but with the nice value (I debug the entire chart after removing an item)

Thank you :)

martynasma commented 4 years ago

OK, so here's the deal.

Chart tries to determine data granularity based on real data. This process is not fail-proof, so it's always best to set it explicitly.

When you set initial series, the bsaeInterval is set to 15 minutes.

After adding second series, it's still at 15 minutes.

After removing it, it for some reason reverts to 1 day granularity.

I'm not ready to jump into your code to see what you're doing on those updates and whether this reset is caused by your code or something in amCharts.

To fix, try setting your data granularity to 15 minutes explicitly on your date axis:

https://www.amcharts.com/docs/v4/concepts/axes/date-axis/#Manually_specifying_data_granularity

hugoperier commented 4 years ago

Oh yeah it work thanks you so much