Rodrigo_V
Rodrigo_V

Reputation: 180

Force a 2 indexes ECharts plot to match 0 on both Y axis

I have the following plot, which uses two axes: one for the differences and another for the accumulative differences.

enter image description here

I would like to force that both y axis match on 0, scaling the second faster later (Note that now we have a 0:200 match)

I´ve been checking the documentation, and it seems to include some variables for this purpose: onZero and onZeroAxisIndex. Nevertheless, I didn´t find any successful solution. Currently, my axis code looks as follows:

xAxis: {
    type: "category",
    data: props.dates,
},
yAxis: [
    {
        type: 'value',
        name: 'Discprepancia',
        position: 'left',
        axisLabel: {
            formatter: '{value} °C'
        },
    },
    {
        type: 'value',
        name: 'Discprepancia acumulada',
        position: 'right',
        alignTicks: true,
        axisLabel: {
            formatter: '{value} °C'
        }
    }
]

Edit: I found a similar question in this thread

Upvotes: 3

Views: 674

Answers (2)

Hucho
Hucho

Reputation: 101

The above answer works, however is hard coded. This works (angular 19 with echarts) dynamically: The main idea is that just the min/max bounds and offset of the primary axis are calculated and then used to calculate the min/max for the secondary axis so that the x-axis intersection matches 0.

interface NumericAxisBounds {
  min: number;
  max: number;
  zeroOffset: number; // Proportional position of the zero point
}

interface SecondaryAxisBounds {
  min: number;
  max: number;
}

export function calculatePrimaryAxisBounds(data: number[]): NumericAxisBounds {
  if (!data.length) {
    return { min: 0, max: 0, zeroOffset: 0 };
  }

  const minValue = Math.min(...data);
  const maxValue = Math.max(...data);

  if (minValue >= 0) {
    // All positive - start at zero
    const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
    const roundedMax = Math.ceil(maxValue / (magnitude / 2)) * (magnitude / 2);
    return {
      min: 0,
      max: roundedMax,
      zeroOffset: 0, // Zero at the bottom
    };
  } else if (maxValue <= 0) {
    // All negative - end at zero
    const magnitude = Math.pow(10, Math.floor(Math.log10(-minValue)));
    const roundedMin = Math.floor(minValue / (magnitude / 2)) * (magnitude / 2);
    return {
      min: roundedMin,
      max: 0,
      zeroOffset: 1, // Zero at the top
    };
  } else {
    // Data crosses zero - dynamic scaling around the range
    // First determine the appropriate magnitude for rounding
    const maxMagnitude = Math.max(Math.abs(maxValue), Math.abs(minValue));
    const roundingUnit = Math.pow(10, Math.floor(Math.log10(maxMagnitude)));
    
    // Use finer granularity for the minimum value since it's smaller
    const adjustedMin = Math.floor(minValue / (roundingUnit / 10)) * (roundingUnit / 10);
    const adjustedMax = Math.ceil(maxValue / (roundingUnit / 2)) * (roundingUnit / 2);
    const range = adjustedMax - adjustedMin;
    const zeroOffset = Math.abs(adjustedMin) / range;

    return {
      min: adjustedMin,
      max: adjustedMax,
      zeroOffset,
    };
  }
}

/**
 * Calculates axis bounds for secondary (percentage) axis that aligns with the primary axis zero point
 * @param data - Array of percentage values (e.g., 1.534 = 153.4%)
 * @param primaryBounds - Object containing the primary axis bounds and zero offset
 */
export function calculateSecondaryAxisBounds(
  data: number[],
  primaryBounds: { min: number; max: number; zeroOffset: number }
): SecondaryAxisBounds {
  if (!data.length) {
    return { min: 0, max: 0 };
  }

  const { zeroOffset } = primaryBounds;
  const actualMin = Math.min(...data);
  const actualMax = Math.max(...data);
  const dataRange = actualMax - actualMin;
  const padding = dataRange * 0.1; // 10% padding

  // Case 1: Primary axis is all positive
  if (primaryBounds.min >= 0) {
    return {
      min: 0,
      max: actualMax + padding
    };
  }

  // Case 2: Primary axis is all negative
  if (primaryBounds.max <= 0) {
    return {
      min: actualMin - padding,
      max: 0
    };
  }

  // Case 3: Primary axis crosses zero
  // For this case, we need to consider two sub-cases:
  
  // Case 3a: Secondary data contains negative values
  if (actualMin < 0) {
    // For a zero position of 25.49%, we need:
    // - Negative range to occupy 25.49% of total range
    // - Positive range to occupy 74.51% of total range
    const rangeFromPositive = actualMax / (1 - zeroOffset); // Positive values take (1-zeroOffset)
    const rangeFromNegative = Math.abs(actualMin) / zeroOffset; // Negative values take zeroOffset
    const totalRequiredRange = Math.max(rangeFromPositive, rangeFromNegative);
    
    return {
      min: -totalRequiredRange * zeroOffset,
      max: totalRequiredRange * (1 - zeroOffset)
    };
  }
  
  // Case 3b: Secondary data is all positive but primary crosses zero
  // Here we need to position the zero point at the same relative position as the primary axis
  const totalRequiredRange = actualMax / (1 - zeroOffset);  // Scale relative to distance from top
  
  return {
    min: -(totalRequiredRange * zeroOffset),  // This creates the negative range needed to position zero at zeroOffset from bottom
    max: actualMax + padding
  };
}

Upvotes: 0

Rodrigo_V
Rodrigo_V

Reputation: 180

In a React/Javascript ecosystem, I solved it by playing with the min and max inside yAxis as follows:

yAxis: [
    // Make both y axis ticks to match
    {
        type: 'value',
        name: 'Desviación',
        // Title is shown on bottom of axis
        interval: 3,
        position: 'left',
        // alignTicks: true,
        axisLabel: {
            formatter: '{value}°C'
        },
        min: function (value) {
            const absValue = max_abs(value.min, value.max)
            const absValueRounded = (absValue * -1).toFixed(0)
            return absValueRounded - (absValueRounded % 3) - 3;
        },
        max: function (value) {
            const absValue = max_abs(value.min, value.max)
            const absValueRounded = (absValue).toFixed(0)
            return absValueRounded - (absValueRounded % 3) + 3;
        },
    },
    {
        type: 'value',
        name: 'Desv. media',
        position: 'right',
        alignTicks: true,
        axisLabel: {
            formatter: '{value}°C'
        },
        min: function (value) {
            const absValue = max_abs(value.min, value.max)
            const absValueRounded = (absValue * -1).toFixed(0)
            return absValueRounded - (absValueRounded % 3) - 3;
        },
        max: function (value) {
            const absValue = max_abs(value.min, value.max)
            const absValueRounded = (absValue).toFixed(0)
            return absValueRounded - (absValueRounded % 3) + 3;
        },
    }
]

As you can see, I played with the value 3, which matches all the plot variations (for each meteorology station). Depending on your needs, You can change it or define it dynamically.

I guess that a similar solution is adaptable to other tech stack.

enter image description here

Upvotes: 1

Related Questions