| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { |
| getAcceptableTickPrecision, |
| isNullableNumberFinite, |
| mathAbs, mathCeil, mathFloor, mathMax, mathRound, nice, NICE_MODE_MIN, quantity, round |
| } from '../util/number'; |
| import IntervalScale from '../scale/Interval'; |
| import LogScale from '../scale/Log'; |
| import type Scale from '../scale/Scale'; |
| import { updateIntervalOrLogScaleForNiceOrAligned } from './axisHelper'; |
| import { warn } from '../util/log'; |
| import { |
| increaseInterval, isLogScale, getIntervalPrecision, intervalScaleEnsureValidExtent, |
| } from '../scale/helper'; |
| import { assert } from 'zrender/src/core/util'; |
| import { adoptScaleRawExtentInfoAndPrepare } from './scaleRawExtentInfo'; |
| import { hasBreaks } from '../scale/break'; |
| import type Axis from './Axis'; |
| |
| |
| /** |
| * NOTE: See the summary of the process of extent determination in the comment of `scaleMapper.setExtent`. |
| * |
| * @see SCALE_EXTENT_CONSTRUCTION for the full processing flow. |
| */ |
| export function scaleCalcAlign( |
| targetAxis: Axis, |
| alignToScale: IntervalScale | LogScale |
| ): void { |
| |
| const targetScale = targetAxis.scale as (IntervalScale | LogScale) & Scale; |
| const targetAxisModel = targetAxis.model; |
| if (__DEV__) { |
| assert(targetScale && targetAxisModel |
| && (targetScale instanceof IntervalScale || targetScale instanceof LogScale) |
| && (alignToScale instanceof IntervalScale || alignToScale instanceof LogScale) |
| ); |
| } |
| const targetExtentInfo = adoptScaleRawExtentInfoAndPrepare( |
| targetScale, targetAxisModel, targetAxisModel.ecModel, targetAxis, null |
| ); |
| |
| // FIXME: |
| // (1) Axis inverse is not considered yet. |
| // (2) `SCALE_EXTENT_KIND_MAPPING` is not considered yet. |
| |
| const isTargetLogScale = isLogScale(targetScale); |
| const alignToScaleLinear = isLogScale(alignToScale) ? alignToScale.intervalStub : alignToScale; |
| const targetIntervalStub = isTargetLogScale ? targetScale.intervalStub : targetScale; |
| |
| const targetLogScaleBase = (targetScale as LogScale).base; |
| const alignToTicks = alignToScaleLinear.getTicks(); |
| const alignToExpNiceTicks = alignToScaleLinear.getTicks({expandToNicedExtent: true}); |
| const alignToSegCount = alignToTicks.length - 1; |
| |
| if (__DEV__) { |
| // This is guards for future changes of `Interval#getTicks`. |
| assert(!hasBreaks(alignToScale) && !hasBreaks(targetScale)); |
| assert(alignToSegCount > 0); // Ticks length >= 2 even on a blank scale. |
| assert(alignToExpNiceTicks.length === alignToTicks.length); |
| assert(alignToTicks[0].value <= alignToTicks[alignToSegCount].value); |
| assert( |
| alignToExpNiceTicks[0].value <= alignToTicks[0].value |
| && alignToTicks[alignToSegCount].value <= alignToExpNiceTicks[alignToSegCount].value |
| ); |
| if (alignToSegCount >= 2) { |
| assert(alignToExpNiceTicks[1].value === alignToTicks[1].value); |
| assert(alignToExpNiceTicks[alignToSegCount - 1].value === alignToTicks[alignToSegCount - 1].value); |
| } |
| } |
| |
| // The Current strategy: Find a proper interval and an extent for the target scale to derive ticks |
| // matching exactly to ticks of `alignTo` scale. |
| |
| // Adjust min, max based on the extent of alignTo. When min or max is set in alignTo scale |
| let t0: number; // diff ratio on min not-nice segment. 0 <= t0 < 1 |
| let t1: number; // diff ratio on max not-nice segment. 0 <= t1 < 1 |
| let alignToNiceSegCount: number; // >= 1 |
| // Consider ticks of `alignTo`, only these cases below may occur: |
| if (alignToSegCount === 1) { |
| // `alignToTicks` is like: |
| // |--| |
| // In this case, we make the corresponding 2 target ticks "nice". |
| t0 = t1 = 0; |
| alignToNiceSegCount = 1; |
| } |
| else if (alignToSegCount === 2) { |
| // `alignToTicks` is like: |
| // |-|-----| or |
| // |-----|-| or |
| // |-----|-----| |
| // Notices that nice ticks do not necessarily exist in this case. |
| // In this case, we choose the larger segment as the "nice segment" and |
| // the corresponding target ticks are made "nice". |
| const interval0 = mathAbs(alignToTicks[0].value - alignToTicks[1].value); |
| const interval1 = mathAbs(alignToTicks[1].value - alignToTicks[2].value); |
| t0 = t1 = 0; |
| if (interval0 === interval1) { |
| alignToNiceSegCount = 2; |
| } |
| else { |
| alignToNiceSegCount = 1; |
| if (interval0 < interval1) { |
| t0 = interval0 / interval1; |
| } |
| else { |
| t1 = interval1 / interval0; |
| } |
| } |
| } |
| else { // alignToSegCount >= 3 |
| // `alignToTicks` is like: |
| // |-|-----|-----|-| or |
| // |-----|-----|-| or |
| // |-|-----|-----| or ... |
| // At least one nice segment is present, and not-nice segments are only present on |
| // the start and/or the end. |
| // In this case, ticks corresponding to nice segments are made "nice". |
| const alignToInterval = alignToScaleLinear.getConfig().interval; |
| t0 = ( |
| 1 - (alignToTicks[0].value - alignToExpNiceTicks[0].value) / alignToInterval |
| ) % 1; |
| t1 = ( |
| 1 - (alignToExpNiceTicks[alignToSegCount].value - alignToTicks[alignToSegCount].value) / alignToInterval |
| ) % 1; |
| alignToNiceSegCount = alignToSegCount - (t0 ? 1 : 0) - (t1 ? 1 : 0); |
| } |
| |
| if (__DEV__) { |
| assert(alignToNiceSegCount >= 1); |
| } |
| |
| // NOTE: |
| // Consider a case: |
| // dataZoom controls all Y axes; |
| // dataZoom end is 90% (maxFixed: true, dataZoomFixMinMax[0]: true); |
| // but dataZoom start is 0% (minFixed: false, dataZoomFixMinMax[1]: false); |
| // In this case, |
| // - `Interval#calcNiceTicks` only uses `targetExtentInfo.max` as the upper bound, but expand the |
| // lower bound to a "nice" tick and can get an acceptable result. |
| // - `scaleCalcAlign` has to use both `targetExtentInfo.min/max` as the bounds without any expansion, |
| // otherwise the lower bound may become negative unexpectedly, especially for all positive series data. |
| const dataZoomFixMinMax = targetExtentInfo.zoomFixMM; |
| const hasDataZoomFixMinMax = dataZoomFixMinMax[0] || dataZoomFixMinMax[1]; |
| const targetMinMaxFixed = [ |
| targetExtentInfo.fixMM[0] || hasDataZoomFixMinMax, |
| targetExtentInfo.fixMM[1] || hasDataZoomFixMinMax |
| ]; |
| // MEMO: When only `xxxAxis.min` or `xxxAxis.max` is fixed, |
| // - Even a "nice" interval can be calculated, ticks accumulated based on `min`/`max` can be "nice" only if |
| // `min` or `max` is a "nice" number. |
| // - Generating a "nice" interval may cause the extent have both positive and negative ticks, which may be |
| // not preferable for all positive (very common) or all negative series data. But it can be simply resolved |
| // by specifying `xxxAxis.min: 0`/`xxxAxis.max: 0`, so we do not specially handle this case here. |
| // Therefore, we prioritize generating "nice" interval over preventing from crossing zero. |
| // e.g., if series data are all positive and the max data is `11739`, |
| // If setting `yAxis.max: 'dataMax'`, ticks may be like: |
| // `11739, 8739, 5739, 2739, -1739` (not "nice" enough) |
| // If setting `yAxis.max: 'dataMax', yAxis.min: 0`, ticks may be like: |
| // `11739, 8805, 5870, 2935, 0` (not "nice" enough but may be acceptable) |
| // If setting `yAxis.max: 12000, yAxis.min: 0`, ticks may be like: |
| // `12000, 9000, 6000, 3000, 0` ("nice") |
| |
| const targetOldOutermostExtent = (targetScale as Scale).getExtent(); |
| const targetOldIntervalExtent = targetIntervalStub.getExtent(); |
| const targetExtent = intervalScaleEnsureValidExtent(targetOldIntervalExtent, targetMinMaxFixed); |
| |
| let min: number; |
| let max: number; |
| let interval: number; |
| let intervalPrecision: number; |
| let maxNice: number; |
| let minNice: number; |
| |
| function loopIncreaseInterval(cb: () => boolean) { |
| // Typically this loop runs less than 5 times. But we still |
| // use a fail-safe for future changes. |
| const LOOP_MAX = 50; |
| let loopGuard = 0; |
| for (; loopGuard < LOOP_MAX; loopGuard++) { |
| if (cb()) { |
| break; |
| } |
| interval = isTargetLogScale |
| // TODO: `mathMax(base, 2)` is a guardcode to avoid infinite loop, |
| // but probably it should be guranteed by `LogScale` itself. |
| ? interval * mathMax(targetLogScaleBase, 2) |
| : increaseInterval(interval); |
| intervalPrecision = getIntervalPrecision(interval); |
| } |
| if (__DEV__) { |
| if (loopGuard >= LOOP_MAX) { |
| warn('incorrect impl in `scaleCalcAlign`.'); |
| } |
| } |
| } |
| |
| function updateMinFromMinNice() { |
| min = round(minNice - interval * t0, intervalPrecision); |
| } |
| function updateMaxFromMaxNice() { |
| max = round(maxNice + interval * t1, intervalPrecision); |
| } |
| function updateMinNiceFromMinT0Interval() { |
| minNice = t0 ? round(min + interval * t0, intervalPrecision) : min; |
| } |
| function updateMaxNiceFromMaxT1Interval() { |
| maxNice = t1 ? round(max - interval * t1, intervalPrecision) : max; |
| } |
| |
| // NOTE: The new calculated `min`/`max` must NOT shrink the original extent; otherwise some series |
| // data may be outside of the extent. They can expand the original extent slightly to align with |
| // ticks of `alignTo`. In this case, more blank space is added but visually fine. |
| |
| if (targetMinMaxFixed[0] && targetMinMaxFixed[1]) { |
| // Both `min` and `max` are specified (via dataZoom or ec option; consider both Cartesian, radar and |
| // other possible axes). In this case, "nice" ticks can hardly be calculated, but reasonable ticks should |
| // still be calculated whenever possible, especially `intervalPrecision` should be tuned for better |
| // appearance and lower cumulative error. |
| |
| min = targetExtent[0]; |
| max = targetExtent[1]; |
| interval = (max - min) / (alignToNiceSegCount + t0 + t1); |
| // Typically axis pixel extent is ready here. See `create` in `Grid.ts`. |
| const axisPxExtent = targetAxis.getExtent(); |
| // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable so far. |
| const pxSpan = mathAbs(axisPxExtent[1] - axisPxExtent[0]); |
| // We imperically choose `pxDiffAcceptable` as `0.5 / alignToNiceSegCount` for reduce cumulative |
| // error, otherwise a discernible misalign (> 1px) may occur. |
| // PENDING: We do not find a acceptable precision for LogScale here. |
| // Theoretically it can be addressed but introduce more complexity. Is it necessary? |
| intervalPrecision = getAcceptableTickPrecision([max, min], pxSpan, 0.5 / alignToNiceSegCount); |
| updateMinNiceFromMinT0Interval(); |
| updateMaxNiceFromMaxT1Interval(); |
| if (isNullableNumberFinite(intervalPrecision)) { |
| interval = round(interval, intervalPrecision); |
| } |
| } |
| else { |
| // Make a minimal enough `interval`, increase it later. |
| // It is a similar logic as `IntervalScale#calcNiceTicks` and `LogScale#calcNiceTicks`. |
| // Axis break is not supported, which is guranteed by the caller of this function. |
| const targetSpan = targetExtent[1] - targetExtent[0]; |
| interval = isTargetLogScale |
| ? mathMax(quantity(targetSpan), 1) |
| : nice(targetSpan / alignToNiceSegCount, NICE_MODE_MIN); |
| intervalPrecision = getIntervalPrecision(interval); |
| |
| if (targetMinMaxFixed[0]) { |
| min = targetExtent[0]; |
| loopIncreaseInterval(function () { |
| updateMinNiceFromMinT0Interval(); |
| maxNice = round(minNice + interval * alignToNiceSegCount, intervalPrecision); |
| updateMaxFromMaxNice(); |
| if (max >= targetExtent[1]) { |
| return true; |
| } |
| }); |
| } |
| else if (targetMinMaxFixed[1]) { |
| max = targetExtent[1]; |
| loopIncreaseInterval(function () { |
| updateMaxNiceFromMaxT1Interval(); |
| minNice = round(maxNice - interval * alignToNiceSegCount, intervalPrecision); |
| updateMinFromMinNice(); |
| if (min <= targetExtent[0]) { |
| return true; |
| } |
| }); |
| } |
| else { |
| loopIncreaseInterval(function () { |
| minNice = round(mathCeil(targetExtent[0] / interval) * interval, intervalPrecision); |
| maxNice = round(mathFloor(targetExtent[1] / interval) * interval, intervalPrecision); |
| // NOTE: |
| // - `maxNice - minNice >= -interval` here. |
| // - While `interval` increases, `currIntervalCount` decreases, minimum `-1`. |
| const currIntervalCount = mathRound((maxNice - minNice) / interval); |
| if (currIntervalCount <= alignToNiceSegCount) { |
| const moreCount = alignToNiceSegCount - currIntervalCount; |
| // Consider cases that negative tick do not make sense (or vice versa), users can simply |
| // specify `xxxAxis.min/max: 0` to avoid negative. But we still automatically handle it |
| // for some common cases whenever possible: |
| // - When ec option is `xxxAxis.scale: false` (the default), it is usually unexpected if |
| // negative (or positive) ticks are introduced. |
| // - In LogScale, series data are usually either all > 1 or all < 1, rather than both, |
| // that is, logarithm result is typically either all positive or all negative. |
| let moreCountPair: number[]; |
| const mayEnhanceZero = targetExtentInfo.incl0 || isTargetLogScale; |
| // `bounds < 0` or `bounds > 0` may require more complex handling, so we only auto handle |
| // `bounds === 0`. |
| if (mayEnhanceZero && targetExtent[0] === 0) { |
| // 0 has been included in extent and all positive. |
| moreCountPair = [0, moreCount]; |
| } |
| else if (mayEnhanceZero && targetExtent[1] === 0) { |
| // 0 has been included in extent and all negative. |
| moreCountPair = [moreCount, 0]; |
| } |
| else { |
| // Try to center ticks in axis space whenever possible, which is especially preferable |
| // in `LogScale`. |
| const lessHalfCount = mathFloor(moreCount / 2); |
| moreCountPair = moreCount % 2 === 0 ? [lessHalfCount, lessHalfCount] |
| : (min + max) < (targetExtent[0] + targetExtent[1]) ? [lessHalfCount, lessHalfCount + 1] |
| : [lessHalfCount + 1, lessHalfCount]; |
| } |
| minNice = round(minNice - interval * moreCountPair[0], intervalPrecision); |
| maxNice = round(maxNice + interval * moreCountPair[1], intervalPrecision); |
| updateMinFromMinNice(); |
| updateMaxFromMaxNice(); |
| if (min <= targetExtent[0] && max >= targetExtent[1]) { |
| return true; |
| } |
| } |
| }); |
| } |
| } |
| |
| updateIntervalOrLogScaleForNiceOrAligned( |
| targetScale, |
| targetMinMaxFixed, |
| targetOldIntervalExtent, |
| [min, max], |
| targetOldOutermostExtent, |
| { |
| // NOTE: Even in LogScale, `interval` should not be in log space. |
| interval, |
| // Force ticks count, otherwise cumulative error may cause more unexpected ticks to be generated. |
| // Though the overlapping tick labels may be auto-ignored, but probably unexpected, e.g., the min |
| // tick label is ignored but the secondary min tick label is shown, which is unexpected when |
| // `axis.min` is user-specified or dataZoom-specified. |
| intervalCount: alignToNiceSegCount, |
| intervalPrecision, |
| niceExtent: [minNice, maxNice], |
| }, |
| ); |
| |
| if (__DEV__) { |
| (targetScale as Scale).freeze(); |
| } |
| } |