blob: e891586a9db3a9b067dcece98026dbdcfbf72364 [file] [log] [blame]
/*
* 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 * as zrUtil from 'zrender/src/core/util';
import BoundingRect from 'zrender/src/core/BoundingRect';
import * as matrix from 'zrender/src/core/matrix';
import * as graphic from '../../util/graphic';
import * as layout from '../../util/layout';
import TimelineView from './TimelineView';
import TimelineAxis from './TimelineAxis';
import { createSymbol } from '../../util/symbol';
import * as axisHelper from '../../coord/axisHelper';
import * as numberUtil from '../../util/number';
import { encodeHTML } from '../../util/format';
var bind = zrUtil.bind;
var each = zrUtil.each;
var PI = Math.PI;
export default TimelineView.extend({
type: 'timeline.slider',
init: function (ecModel, api) {
this.api = api;
/**
* @private
* @type {module:echarts/component/timeline/TimelineAxis}
*/
this._axis;
/**
* @private
* @type {module:zrender/core/BoundingRect}
*/
this._viewRect;
/**
* @type {number}
*/
this._timer;
/**
* @type {module:zrender/Element}
*/
this._currentPointer;
/**
* @type {module:zrender/container/Group}
*/
this._mainGroup;
/**
* @type {module:zrender/container/Group}
*/
this._labelGroup;
},
/**
* @override
*/
render: function (timelineModel, ecModel, api, payload) {
this.model = timelineModel;
this.api = api;
this.ecModel = ecModel;
this.group.removeAll();
if (timelineModel.get('show', true)) {
var layoutInfo = this._layout(timelineModel, api);
var mainGroup = this._createGroup('mainGroup');
var labelGroup = this._createGroup('labelGroup');
/**
* @private
* @type {module:echarts/component/timeline/TimelineAxis}
*/
var axis = this._axis = this._createAxis(layoutInfo, timelineModel);
timelineModel.formatTooltip = function (dataIndex) {
return encodeHTML(axis.scale.getLabel(dataIndex));
};
each(['AxisLine', 'AxisTick', 'Control', 'CurrentPointer'], function (name) {
this['_render' + name](layoutInfo, mainGroup, axis, timelineModel);
}, this);
this._renderAxisLabel(layoutInfo, labelGroup, axis, timelineModel);
this._position(layoutInfo, timelineModel);
}
this._doPlayStop();
},
/**
* @override
*/
remove: function () {
this._clearTimer();
this.group.removeAll();
},
/**
* @override
*/
dispose: function () {
this._clearTimer();
},
_layout: function (timelineModel, api) {
var labelPosOpt = timelineModel.get('label.position');
var orient = timelineModel.get('orient');
var viewRect = getViewRect(timelineModel, api); // Auto label offset.
if (labelPosOpt == null || labelPosOpt === 'auto') {
labelPosOpt = orient === 'horizontal' ? viewRect.y + viewRect.height / 2 < api.getHeight() / 2 ? '-' : '+' : viewRect.x + viewRect.width / 2 < api.getWidth() / 2 ? '+' : '-';
} else if (isNaN(labelPosOpt)) {
labelPosOpt = {
horizontal: {
top: '-',
bottom: '+'
},
vertical: {
left: '-',
right: '+'
}
}[orient][labelPosOpt];
}
var labelAlignMap = {
horizontal: 'center',
vertical: labelPosOpt >= 0 || labelPosOpt === '+' ? 'left' : 'right'
};
var labelBaselineMap = {
horizontal: labelPosOpt >= 0 || labelPosOpt === '+' ? 'top' : 'bottom',
vertical: 'middle'
};
var rotationMap = {
horizontal: 0,
vertical: PI / 2
}; // Position
var mainLength = orient === 'vertical' ? viewRect.height : viewRect.width;
var controlModel = timelineModel.getModel('controlStyle');
var showControl = controlModel.get('show', true);
var controlSize = showControl ? controlModel.get('itemSize') : 0;
var controlGap = showControl ? controlModel.get('itemGap') : 0;
var sizePlusGap = controlSize + controlGap; // Special label rotate.
var labelRotation = timelineModel.get('label.rotate') || 0;
labelRotation = labelRotation * PI / 180; // To radian.
var playPosition;
var prevBtnPosition;
var nextBtnPosition;
var axisExtent;
var controlPosition = controlModel.get('position', true);
var showPlayBtn = showControl && controlModel.get('showPlayBtn', true);
var showPrevBtn = showControl && controlModel.get('showPrevBtn', true);
var showNextBtn = showControl && controlModel.get('showNextBtn', true);
var xLeft = 0;
var xRight = mainLength; // position[0] means left, position[1] means middle.
if (controlPosition === 'left' || controlPosition === 'bottom') {
showPlayBtn && (playPosition = [0, 0], xLeft += sizePlusGap);
showPrevBtn && (prevBtnPosition = [xLeft, 0], xLeft += sizePlusGap);
showNextBtn && (nextBtnPosition = [xRight - controlSize, 0], xRight -= sizePlusGap);
} else {
// 'top' 'right'
showPlayBtn && (playPosition = [xRight - controlSize, 0], xRight -= sizePlusGap);
showPrevBtn && (prevBtnPosition = [0, 0], xLeft += sizePlusGap);
showNextBtn && (nextBtnPosition = [xRight - controlSize, 0], xRight -= sizePlusGap);
}
axisExtent = [xLeft, xRight];
if (timelineModel.get('inverse')) {
axisExtent.reverse();
}
return {
viewRect: viewRect,
mainLength: mainLength,
orient: orient,
rotation: rotationMap[orient],
labelRotation: labelRotation,
labelPosOpt: labelPosOpt,
labelAlign: timelineModel.get('label.align') || labelAlignMap[orient],
labelBaseline: timelineModel.get('label.verticalAlign') || timelineModel.get('label.baseline') || labelBaselineMap[orient],
// Based on mainGroup.
playPosition: playPosition,
prevBtnPosition: prevBtnPosition,
nextBtnPosition: nextBtnPosition,
axisExtent: axisExtent,
controlSize: controlSize,
controlGap: controlGap
};
},
_position: function (layoutInfo, timelineModel) {
// Position is be called finally, because bounding rect is needed for
// adapt content to fill viewRect (auto adapt offset).
// Timeline may be not all in the viewRect when 'offset' is specified
// as a number, because it is more appropriate that label aligns at
// 'offset' but not the other edge defined by viewRect.
var mainGroup = this._mainGroup;
var labelGroup = this._labelGroup;
var viewRect = layoutInfo.viewRect;
if (layoutInfo.orient === 'vertical') {
// transform to horizontal, inverse rotate by left-top point.
var m = matrix.create();
var rotateOriginX = viewRect.x;
var rotateOriginY = viewRect.y + viewRect.height;
matrix.translate(m, m, [-rotateOriginX, -rotateOriginY]);
matrix.rotate(m, m, -PI / 2);
matrix.translate(m, m, [rotateOriginX, rotateOriginY]);
viewRect = viewRect.clone();
viewRect.applyTransform(m);
}
var viewBound = getBound(viewRect);
var mainBound = getBound(mainGroup.getBoundingRect());
var labelBound = getBound(labelGroup.getBoundingRect());
var mainPosition = mainGroup.position;
var labelsPosition = labelGroup.position;
labelsPosition[0] = mainPosition[0] = viewBound[0][0];
var labelPosOpt = layoutInfo.labelPosOpt;
if (isNaN(labelPosOpt)) {
// '+' or '-'
var mainBoundIdx = labelPosOpt === '+' ? 0 : 1;
toBound(mainPosition, mainBound, viewBound, 1, mainBoundIdx);
toBound(labelsPosition, labelBound, viewBound, 1, 1 - mainBoundIdx);
} else {
var mainBoundIdx = labelPosOpt >= 0 ? 0 : 1;
toBound(mainPosition, mainBound, viewBound, 1, mainBoundIdx);
labelsPosition[1] = mainPosition[1] + labelPosOpt;
}
mainGroup.attr('position', mainPosition);
labelGroup.attr('position', labelsPosition);
mainGroup.rotation = labelGroup.rotation = layoutInfo.rotation;
setOrigin(mainGroup);
setOrigin(labelGroup);
function setOrigin(targetGroup) {
var pos = targetGroup.position;
targetGroup.origin = [viewBound[0][0] - pos[0], viewBound[1][0] - pos[1]];
}
function getBound(rect) {
// [[xmin, xmax], [ymin, ymax]]
return [[rect.x, rect.x + rect.width], [rect.y, rect.y + rect.height]];
}
function toBound(fromPos, from, to, dimIdx, boundIdx) {
fromPos[dimIdx] += to[dimIdx][boundIdx] - from[dimIdx][boundIdx];
}
},
_createAxis: function (layoutInfo, timelineModel) {
var data = timelineModel.getData();
var axisType = timelineModel.get('axisType');
var scale = axisHelper.createScaleByModel(timelineModel, axisType); // Customize scale. The `tickValue` is `dataIndex`.
scale.getTicks = function () {
return data.mapArray(['value'], function (value) {
return value;
});
};
var dataExtent = data.getDataExtent('value');
scale.setExtent(dataExtent[0], dataExtent[1]);
scale.niceTicks();
var axis = new TimelineAxis('value', scale, layoutInfo.axisExtent, axisType);
axis.model = timelineModel;
return axis;
},
_createGroup: function (name) {
var newGroup = this['_' + name] = new graphic.Group();
this.group.add(newGroup);
return newGroup;
},
_renderAxisLine: function (layoutInfo, group, axis, timelineModel) {
var axisExtent = axis.getExtent();
if (!timelineModel.get('lineStyle.show')) {
return;
}
group.add(new graphic.Line({
shape: {
x1: axisExtent[0],
y1: 0,
x2: axisExtent[1],
y2: 0
},
style: zrUtil.extend({
lineCap: 'round'
}, timelineModel.getModel('lineStyle').getLineStyle()),
silent: true,
z2: 1
}));
},
/**
* @private
*/
_renderAxisTick: function (layoutInfo, group, axis, timelineModel) {
var data = timelineModel.getData(); // Show all ticks, despite ignoring strategy.
var ticks = axis.scale.getTicks(); // The value is dataIndex, see the costomized scale.
each(ticks, function (value) {
var tickCoord = axis.dataToCoord(value);
var itemModel = data.getItemModel(value);
var itemStyleModel = itemModel.getModel('itemStyle');
var hoverStyleModel = itemModel.getModel('emphasis.itemStyle');
var symbolOpt = {
position: [tickCoord, 0],
onclick: bind(this._changeTimeline, this, value)
};
var el = giveSymbol(itemModel, itemStyleModel, group, symbolOpt);
graphic.setHoverStyle(el, hoverStyleModel.getItemStyle());
if (itemModel.get('tooltip')) {
el.dataIndex = value;
el.dataModel = timelineModel;
} else {
el.dataIndex = el.dataModel = null;
}
}, this);
},
/**
* @private
*/
_renderAxisLabel: function (layoutInfo, group, axis, timelineModel) {
var labelModel = axis.getLabelModel();
if (!labelModel.get('show')) {
return;
}
var data = timelineModel.getData();
var labels = axis.getViewLabels();
each(labels, function (labelItem) {
// The tickValue is dataIndex, see the costomized scale.
var dataIndex = labelItem.tickValue;
var itemModel = data.getItemModel(dataIndex);
var normalLabelModel = itemModel.getModel('label');
var hoverLabelModel = itemModel.getModel('emphasis.label');
var tickCoord = axis.dataToCoord(labelItem.tickValue);
var textEl = new graphic.Text({
position: [tickCoord, 0],
rotation: layoutInfo.labelRotation - layoutInfo.rotation,
onclick: bind(this._changeTimeline, this, dataIndex),
silent: false
});
graphic.setTextStyle(textEl.style, normalLabelModel, {
text: labelItem.formattedLabel,
textAlign: layoutInfo.labelAlign,
textVerticalAlign: layoutInfo.labelBaseline
});
group.add(textEl);
graphic.setHoverStyle(textEl, graphic.setTextStyle({}, hoverLabelModel));
}, this);
},
/**
* @private
*/
_renderControl: function (layoutInfo, group, axis, timelineModel) {
var controlSize = layoutInfo.controlSize;
var rotation = layoutInfo.rotation;
var itemStyle = timelineModel.getModel('controlStyle').getItemStyle();
var hoverStyle = timelineModel.getModel('emphasis.controlStyle').getItemStyle();
var rect = [0, -controlSize / 2, controlSize, controlSize];
var playState = timelineModel.getPlayState();
var inverse = timelineModel.get('inverse', true);
makeBtn(layoutInfo.nextBtnPosition, 'controlStyle.nextIcon', bind(this._changeTimeline, this, inverse ? '-' : '+'));
makeBtn(layoutInfo.prevBtnPosition, 'controlStyle.prevIcon', bind(this._changeTimeline, this, inverse ? '+' : '-'));
makeBtn(layoutInfo.playPosition, 'controlStyle.' + (playState ? 'stopIcon' : 'playIcon'), bind(this._handlePlayClick, this, !playState), true);
function makeBtn(position, iconPath, onclick, willRotate) {
if (!position) {
return;
}
var opt = {
position: position,
origin: [controlSize / 2, 0],
rotation: willRotate ? -rotation : 0,
rectHover: true,
style: itemStyle,
onclick: onclick
};
var btn = makeIcon(timelineModel, iconPath, rect, opt);
group.add(btn);
graphic.setHoverStyle(btn, hoverStyle);
}
},
_renderCurrentPointer: function (layoutInfo, group, axis, timelineModel) {
var data = timelineModel.getData();
var currentIndex = timelineModel.getCurrentIndex();
var pointerModel = data.getItemModel(currentIndex).getModel('checkpointStyle');
var me = this;
var callback = {
onCreate: function (pointer) {
pointer.draggable = true;
pointer.drift = bind(me._handlePointerDrag, me);
pointer.ondragend = bind(me._handlePointerDragend, me);
pointerMoveTo(pointer, currentIndex, axis, timelineModel, true);
},
onUpdate: function (pointer) {
pointerMoveTo(pointer, currentIndex, axis, timelineModel);
}
}; // Reuse when exists, for animation and drag.
this._currentPointer = giveSymbol(pointerModel, pointerModel, this._mainGroup, {}, this._currentPointer, callback);
},
_handlePlayClick: function (nextState) {
this._clearTimer();
this.api.dispatchAction({
type: 'timelinePlayChange',
playState: nextState,
from: this.uid
});
},
_handlePointerDrag: function (dx, dy, e) {
this._clearTimer();
this._pointerChangeTimeline([e.offsetX, e.offsetY]);
},
_handlePointerDragend: function (e) {
this._pointerChangeTimeline([e.offsetX, e.offsetY], true);
},
_pointerChangeTimeline: function (mousePos, trigger) {
var toCoord = this._toAxisCoord(mousePos)[0];
var axis = this._axis;
var axisExtent = numberUtil.asc(axis.getExtent().slice());
toCoord > axisExtent[1] && (toCoord = axisExtent[1]);
toCoord < axisExtent[0] && (toCoord = axisExtent[0]);
this._currentPointer.position[0] = toCoord;
this._currentPointer.dirty();
var targetDataIndex = this._findNearestTick(toCoord);
var timelineModel = this.model;
if (trigger || targetDataIndex !== timelineModel.getCurrentIndex() && timelineModel.get('realtime')) {
this._changeTimeline(targetDataIndex);
}
},
_doPlayStop: function () {
this._clearTimer();
if (this.model.getPlayState()) {
this._timer = setTimeout(bind(handleFrame, this), this.model.get('playInterval'));
}
function handleFrame() {
// Do not cache
var timelineModel = this.model;
this._changeTimeline(timelineModel.getCurrentIndex() + (timelineModel.get('rewind', true) ? -1 : 1));
}
},
_toAxisCoord: function (vertex) {
var trans = this._mainGroup.getLocalTransform();
return graphic.applyTransform(vertex, trans, true);
},
_findNearestTick: function (axisCoord) {
var data = this.model.getData();
var dist = Infinity;
var targetDataIndex;
var axis = this._axis;
data.each(['value'], function (value, dataIndex) {
var coord = axis.dataToCoord(value);
var d = Math.abs(coord - axisCoord);
if (d < dist) {
dist = d;
targetDataIndex = dataIndex;
}
});
return targetDataIndex;
},
_clearTimer: function () {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
},
_changeTimeline: function (nextIndex) {
var currentIndex = this.model.getCurrentIndex();
if (nextIndex === '+') {
nextIndex = currentIndex + 1;
} else if (nextIndex === '-') {
nextIndex = currentIndex - 1;
}
this.api.dispatchAction({
type: 'timelineChange',
currentIndex: nextIndex,
from: this.uid
});
}
});
function getViewRect(model, api) {
return layout.getLayoutRect(model.getBoxLayoutParams(), {
width: api.getWidth(),
height: api.getHeight()
}, model.get('padding'));
}
function makeIcon(timelineModel, objPath, rect, opts) {
var icon = graphic.makePath(timelineModel.get(objPath).replace(/^path:\/\//, ''), zrUtil.clone(opts || {}), new BoundingRect(rect[0], rect[1], rect[2], rect[3]), 'center');
return icon;
}
/**
* Create symbol or update symbol
* opt: basic position and event handlers
*/
function giveSymbol(hostModel, itemStyleModel, group, opt, symbol, callback) {
var color = itemStyleModel.get('color');
if (!symbol) {
var symbolType = hostModel.get('symbol');
symbol = createSymbol(symbolType, -1, -1, 2, 2, color);
symbol.setStyle('strokeNoScale', true);
group.add(symbol);
callback && callback.onCreate(symbol);
} else {
symbol.setColor(color);
group.add(symbol); // Group may be new, also need to add.
callback && callback.onUpdate(symbol);
} // Style
var itemStyle = itemStyleModel.getItemStyle(['color', 'symbol', 'symbolSize']);
symbol.setStyle(itemStyle); // Transform and events.
opt = zrUtil.merge({
rectHover: true,
z2: 100
}, opt, true);
var symbolSize = hostModel.get('symbolSize');
symbolSize = symbolSize instanceof Array ? symbolSize.slice() : [+symbolSize, +symbolSize];
symbolSize[0] /= 2;
symbolSize[1] /= 2;
opt.scale = symbolSize;
var symbolOffset = hostModel.get('symbolOffset');
if (symbolOffset) {
var pos = opt.position = opt.position || [0, 0];
pos[0] += numberUtil.parsePercent(symbolOffset[0], symbolSize[0]);
pos[1] += numberUtil.parsePercent(symbolOffset[1], symbolSize[1]);
}
var symbolRotate = hostModel.get('symbolRotate');
opt.rotation = (symbolRotate || 0) * Math.PI / 180 || 0;
symbol.attr(opt); // FIXME
// (1) When symbol.style.strokeNoScale is true and updateTransform is not performed,
// getBoundingRect will return wrong result.
// (This is supposed to be resolved in zrender, but it is a little difficult to
// leverage performance and auto updateTransform)
// (2) All of ancesters of symbol do not scale, so we can just updateTransform symbol.
symbol.updateTransform();
return symbol;
}
function pointerMoveTo(pointer, dataIndex, axis, timelineModel, noAnimation) {
if (pointer.dragging) {
return;
}
var pointerModel = timelineModel.getModel('checkpointStyle');
var toCoord = axis.dataToCoord(timelineModel.getData().get(['value'], dataIndex));
if (noAnimation || !pointerModel.get('animation', true)) {
pointer.attr({
position: [toCoord, 0]
});
} else {
pointer.stopAnimation(true);
pointer.animateTo({
position: [toCoord, 0]
}, pointerModel.get('animationDuration', true), pointerModel.get('animationEasing', true));
}
}