Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(scatter): jittering for category data #19941

Open
wants to merge 10 commits into
base: next
Choose a base branch
from
41 changes: 39 additions & 2 deletions src/chart/helper/SymbolDraw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ import { ScatterSeriesOption } from '../scatter/ScatterSeries';
import { getLabelStatesModels } from '../../label/labelStyle';
import Element from 'zrender/src/Element';
import SeriesModel from '../../model/Series';
import { fixJitter, needFixJitter } from '../../util/jitter';
import type Axis2D from '../../coord/cartesian/Axis2D';
import type SingleAxis from '../../coord/single/SingleAxis';

interface UpdateOpt {
isIgnore?(idx: number): boolean
Expand Down Expand Up @@ -182,17 +185,51 @@ class SymbolDraw {
opt = normalizeUpdateOpt(opt);

const group = this.group;
const seriesModel = data.hostModel;
const seriesModel = data.hostModel as SeriesModel;
const oldData = this._data;
const SymbolCtor = this._SymbolCtor;
const disableAnimation = opt.disableAnimation;
const coord = seriesModel.coordinateSystem;
const baseAxis = coord.getBaseAxis ? coord.getBaseAxis() : null;
const hasJitter = baseAxis && needFixJitter(seriesModel, baseAxis);

const seriesScope = makeSeriesScope(data);

const symbolUpdateOpt = { disableAnimation };

const getSymbolPoint = opt.getSymbolPoint || function (idx: number) {
return data.getItemLayout(idx);
const layout = data.getItemLayout(idx);
const rawSize = data.getItemVisual(idx, 'symbolSize');
const size = rawSize instanceof Array ? (rawSize[1] + rawSize[0]) / 2 : rawSize;

// return layout
if (hasJitter) {
const dim = baseAxis.dim;
const orient = (baseAxis as SingleAxis).orient;
const isSingleY = orient === 'horizontal' && baseAxis.type !== 'category'
|| orient === 'vertical' && baseAxis.type === 'category';
if (dim === 'y' || dim === 'single' && isSingleY) {
// x is fixed, and y is floating
const jittered = fixJitter(
baseAxis as Axis2D | SingleAxis,
layout[0],
layout[1],
size / 2
);
return [layout[0], jittered];
}
else if (dim === 'x' || dim === 'single' && !isSingleY) {
// y is fixed, and x is floating
const jittered = fixJitter(
baseAxis as Axis2D | SingleAxis,
layout[1],
layout[0],
size / 2
);
return [jittered, layout[1]];
}
}
return layout;
};


Expand Down
3 changes: 3 additions & 0 deletions src/coord/axisCommonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export interface AxisBaseOptionCommon extends ComponentOption,
*/
max?: ScaleDataValue | 'dataMax' | ((extent: {min: number, max: number}) => ScaleDataValue);

jitter?: number;
jitterOverlap?: boolean;
jitterMargin?: number;
}

export interface NumericAxisBaseOptionCommon extends AxisBaseOptionCommon {
Expand Down
3 changes: 3 additions & 0 deletions src/coord/axisDefault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ const categoryAxis: AxisBaseOption = zrUtil.merge({
boundaryGap: true,
// Set false to faster category collection.
deduplication: null,
jitter: 0,
jitterOverlap: true,
jitterMargin: 2,
// splitArea: {
// show: false
// },
Expand Down
6 changes: 5 additions & 1 deletion src/coord/single/AxisModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ class SingleAxisModel extends ComponentModel<SingleAxisOption>
type: 'dashed',
opacity: 0.2
}
}
},

jitter: 0,
jitterOverlap: true,
jitterMargin: 2,
};
}

Expand Down
129 changes: 129 additions & 0 deletions src/util/jitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type Axis from '../coord/Axis';
import type { AxisBaseModel } from '../coord/AxisBaseModel';
import Axis2D from '../coord/cartesian/Axis2D';
import type SingleAxis from '../coord/single/SingleAxis';
import type SeriesModel from '../model/Series';
import { makeInner } from './model';

export function needFixJitter(seriesModel: SeriesModel, axis: Axis): boolean {
const { coordinateSystem } = seriesModel;
const { type: coordType } = coordinateSystem;
const baseAxis = coordinateSystem.getBaseAxis();
const { type: scaleType } = baseAxis.scale;
const seriesValid = coordType === 'cartesian2d'
&& (scaleType === 'category' || scaleType === 'ordinal')
|| coordType === 'single';

const axisValid = (axis.model as AxisBaseModel).get('jitter') > 0;
return seriesValid && axisValid;
}

export type JitterData = {
fixedCoord: number,
floatCoord: number,
r: number
};

const inner = makeInner<{ items: JitterData[] }, Axis2D | SingleAxis>();

/**
* Fix jitter for overlapping data points.
*
* @param fixedAxis The axis whose coord doesn't change with jitter.
* @param fixedCoord The coord of fixedAxis.
* @param floatCoord The coord of the other axis, which should be changed with jittering.
* @param radius The radius of the data point, considering the symbol is a circle.
* @returns updated floatCoord.
*/
export function fixJitter(
fixedAxis: Axis2D | SingleAxis,
fixedCoord: number,
floatCoord: number,
radius: number
): number {
if (fixedAxis instanceof Axis2D) {
const scaleType = fixedAxis.scale.type;
if (scaleType !== 'category' && scaleType !== 'ordinal') {
return floatCoord;
}
}
const axisModel = fixedAxis.model as AxisBaseModel;
const jitter = axisModel.get('jitter');
const jitterOverlap = axisModel.get('jitterOverlap');
const jitterMargin = axisModel.get('jitterMargin') || 0;
if (jitter > 0) {
if (jitterOverlap) {
return fixJitterIgnoreOverlaps(floatCoord, jitter);
}
else {
return fixJitterAvoidOverlaps(fixedAxis, fixedCoord, floatCoord, radius, jitter, jitterMargin);
}
}
return floatCoord;
}

function fixJitterIgnoreOverlaps(floatCoord: number, jitter: number): number {
return floatCoord + (Math.random() - 0.5) * jitter;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think also need to clamp with the coordinate system boundary, otherwise the invalid outcome coords will cause that some points can not be draw and hard to be discovered by developers. The same goes for the overlap processing.

}

function fixJitterAvoidOverlaps(
fixedAxis: Axis2D | SingleAxis,
fixedCoord: number,
floatCoord: number,
radius: number,
jitter: number,
margin: number
): number {
const store = inner(fixedAxis);
if (!store.items) {
store.items = [];
}
const items = store.items;

const floatA = placeJitterOnDirection(items, fixedCoord, floatCoord, radius, jitter, margin, 1);
const floatB = placeJitterOnDirection(items, fixedCoord, floatCoord, radius, jitter, margin, -1);
let minFloat = Math.abs(floatA - floatCoord) < Math.abs(floatB - floatCoord) ? floatA : floatB;
if (Math.abs(minFloat - floatCoord) > jitter / 2) {
// If the new item is moved too far, then give up.
// Fall back to random jitter.
minFloat = fixJitterIgnoreOverlaps(floatCoord, jitter);
}

items.push({ fixedCoord, floatCoord: minFloat, r: radius });
return minFloat;
}

function placeJitterOnDirection(
items: JitterData[],
fixedCoord: number,
floatCoord: number,
radius: number,
jitter: number,
margin: number,
direction: 1 | -1
) {
// Check for overlap with previous items.
let y = floatCoord;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const dx = fixedCoord - item.fixedCoord;
const dy = y - item.floatCoord;
const d2 = dx * dx + dy * dy;
const r = radius + item.r + margin;
if (d2 < r * r) {
// Overlap. Try to move the new item along otherCoord direction.
const newY = item.floatCoord + Math.sqrt(r * r - dx * dx) * direction;
if (direction > 0 && newY > y || direction < 0 && newY < y) {
y = newY;
i = 0; // Back to check from the first item.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if the items is ordered by floatCoord, the backtracking (i = 0) here is not necessary. With this backtracking, the entire algorithm could degraded to O(n^3) in the worst case. But without it, it can keep O(n^2).

Just have a try, to modify it (with a linked list and sort in ascending order by floatCoord):

export type JitterData = {
    fixedCoord: number;
    floatCoord: number;
    r: number;
    next: JitterData | null;
    prev: JitterData | null;
};

// Items is a circular linked list, in the ascending order by floatCoord.
const inner = makeInner<{ items: JitterData }, Axis2D | SingleAxis>();

function fixJitterAvoidOverlaps(
    fixedAxis: Axis2D | SingleAxis,
    fixedCoord: number,
    floatCoord: number,
    radius: number,
    jitter: number,
    margin: number
): number {
    const store = inner(fixedAxis);
    if (!store.items) {
        store.items = {
            fixedCoord: -1,
            floatCoord: -1,
            r: -1,
            next: null, // head of a link list
            prev: null, // tail of a link list
        };
        store.items.next = store.items;
        store.items.prev = store.items;
    }
    const items = store.items;

    const overlapA = placeJitterOnDirection(items, fixedCoord, floatCoord, radius, jitter, margin, 1);
    const overlapB = placeJitterOnDirection(items, fixedCoord, floatCoord, radius, jitter, margin, -1);
    const overlapResult = Math.abs(overlapA.resultCoord - floatCoord) < Math.abs(overlapB.resultCoord - floatCoord)
        ? overlapA : overlapB;
    let minFloat = overlapResult.resultCoord;
    if (Math.abs(minFloat - floatCoord) > jitter / 2) {
        // If the new item is moved too far, then give up.
        // Fall back to random jitter.
        minFloat = fixJitterIgnoreOverlaps(floatCoord, jitter);
    }

    // Insert to store
    const insertBy = overlapResult.insertBy;
    const resultDirection = overlapResult.direction;
    const pointer1 = resultDirection > 0 ? 'next' : 'prev';
    const pointer2 = resultDirection > 0 ? 'prev' : 'next';
    const newItem: JitterData = {
        fixedCoord: fixedCoord,
        floatCoord: overlapResult.resultCoord,
        r: radius,
        next: null,
        prev: null,
    };
    newItem[pointer1] = insertBy[pointer1];
    newItem[pointer2] = insertBy;
    insertBy[pointer1][pointer2] = newItem;
    insertBy[pointer1] = newItem;

    return minFloat;
}

function placeJitterOnDirection(
    items: JitterData,
    fixedCoord: number,
    floatCoord: number,
    radius: number,
    jitter: number,
    margin: number,
    direction: 1 | -1
): {
    resultCoord: number;
    insertBy: JitterData;
    direction: 1 | -1;
} {
    // Check for overlap with previous items.
    let y = floatCoord;
    const pointer1 = direction > 0 ? 'next' : 'prev';
    let insertBy = items;
    let item = items[pointer1];

    while (item !== items) {
        const dx = fixedCoord - item.fixedCoord;
        const dy = y - item.floatCoord;
        const d2 = dx * dx + dy * dy;
        const r = radius + item.r + margin;
        if (d2 < r * r) {
            // Overlap. Try to move the new item along otherCoord direction.
            y = item.floatCoord + Math.sqrt(r * r - dx * dx) * direction;
            insertBy = item;

            if (Math.abs(y - floatCoord) > jitter / 2) {
                // If the new item is moved too far, then give up.
                // Fall back to random jitter.
                return {resultCoord: Number.MAX_VALUE, insertBy, direction};
            }
        }

        item = item[pointer1];
    }

    return {resultCoord: y, insertBy, direction};
}

}

if (Math.abs(newY - floatCoord) > jitter / 2) {
// If the new item is moved too far, then give up.
// Fall back to random jitter.
return Number.MAX_VALUE;
}
}
}
return y;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering the size issue, should we consider that introduce this kind of features on demand rather than including it by default?
That is, support to import it by users manually.
I'm not sure yet 🤔.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've run the release script and checked the increased package size of this PR is 5KB (0.15% to the package size) before minifying. So I think it's easier to be imported by default.

Loading