|  | /* | 
|  | * 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. | 
|  | */ | 
|  |  | 
|  | /** | 
|  | * Separate legend and scrollable legend to reduce package size. | 
|  | */ | 
|  |  | 
|  | import * as zrUtil from 'zrender/src/core/util'; | 
|  | import * as graphic from '../../util/graphic'; | 
|  | import * as layoutUtil from '../../util/layout'; | 
|  | import LegendView from './LegendView'; | 
|  | import { LegendSelectorButtonOption } from './LegendModel'; | 
|  | import ExtensionAPI from '../../core/ExtensionAPI'; | 
|  | import GlobalModel from '../../model/Global'; | 
|  | import ScrollableLegendModel, {ScrollableLegendOption} from './ScrollableLegendModel'; | 
|  | import Displayable from 'zrender/src/graphic/Displayable'; | 
|  | import Element from 'zrender/src/Element'; | 
|  | import { ZRRectLike } from '../../util/types'; | 
|  |  | 
|  | const Group = graphic.Group; | 
|  |  | 
|  | const WH = ['width', 'height'] as const; | 
|  | const XY = ['x', 'y'] as const; | 
|  |  | 
|  | interface PageInfo { | 
|  | contentPosition: number[] | 
|  | pageCount: number | 
|  | pageIndex: number | 
|  | pagePrevDataIndex: number | 
|  | pageNextDataIndex: number | 
|  | } | 
|  |  | 
|  | interface ItemInfo { | 
|  | /** | 
|  | * Start | 
|  | */ | 
|  | s: number | 
|  | /** | 
|  | * End | 
|  | */ | 
|  | e: number | 
|  | /** | 
|  | * Index | 
|  | */ | 
|  | i: number | 
|  | } | 
|  |  | 
|  | type LegendGroup = graphic.Group & { | 
|  | __rectSize: number | 
|  | }; | 
|  |  | 
|  | type LegendItemElement = Element & { | 
|  | __legendDataIndex: number | 
|  | }; | 
|  |  | 
|  | class ScrollableLegendView extends LegendView { | 
|  |  | 
|  | static type = 'legend.scroll' as const; | 
|  | type = ScrollableLegendView.type; | 
|  |  | 
|  | newlineDisabled = true; | 
|  |  | 
|  | private _containerGroup: LegendGroup; | 
|  | private _controllerGroup: graphic.Group; | 
|  |  | 
|  | private _currentIndex: number = 0; | 
|  |  | 
|  | private _showController: boolean; | 
|  |  | 
|  | init() { | 
|  |  | 
|  | super.init(); | 
|  |  | 
|  | this.group.add(this._containerGroup = new Group() as LegendGroup); | 
|  | this._containerGroup.add(this.getContentGroup()); | 
|  |  | 
|  | this.group.add(this._controllerGroup = new Group()); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @override | 
|  | */ | 
|  | resetInner() { | 
|  | super.resetInner(); | 
|  |  | 
|  | this._controllerGroup.removeAll(); | 
|  | this._containerGroup.removeClipPath(); | 
|  | this._containerGroup.__rectSize = null; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @override | 
|  | */ | 
|  | renderInner( | 
|  | itemAlign: ScrollableLegendOption['align'], | 
|  | legendModel: ScrollableLegendModel, | 
|  | ecModel: GlobalModel, | 
|  | api: ExtensionAPI, | 
|  | selector: LegendSelectorButtonOption[], | 
|  | orient: ScrollableLegendOption['orient'], | 
|  | selectorPosition: ScrollableLegendOption['selectorPosition'] | 
|  | ) { | 
|  | const self = this; | 
|  |  | 
|  | // Render content items. | 
|  | super.renderInner(itemAlign, legendModel, ecModel, api, selector, orient, selectorPosition); | 
|  |  | 
|  | const controllerGroup = this._controllerGroup; | 
|  |  | 
|  | // FIXME: support be 'auto' adapt to size number text length, | 
|  | // e.g., '3/12345' should not overlap with the control arrow button. | 
|  | const pageIconSize = legendModel.get('pageIconSize', true); | 
|  | const pageIconSizeArr: number[] = zrUtil.isArray(pageIconSize) | 
|  | ? pageIconSize : [pageIconSize, pageIconSize]; | 
|  |  | 
|  | createPageButton('pagePrev', 0); | 
|  |  | 
|  | const pageTextStyleModel = legendModel.getModel('pageTextStyle'); | 
|  | controllerGroup.add(new graphic.Text({ | 
|  | name: 'pageText', | 
|  | style: { | 
|  | // Placeholder to calculate a proper layout. | 
|  | text: 'xx/xx', | 
|  | fill: pageTextStyleModel.getTextColor(), | 
|  | font: pageTextStyleModel.getFont(), | 
|  | verticalAlign: 'middle', | 
|  | align: 'center' | 
|  | }, | 
|  | silent: true | 
|  | })); | 
|  |  | 
|  | createPageButton('pageNext', 1); | 
|  |  | 
|  | function createPageButton(name: string, iconIdx: number) { | 
|  | const pageDataIndexName = (name + 'DataIndex') as 'pagePrevDataIndex' | 'pageNextDataIndex'; | 
|  | const icon = graphic.createIcon( | 
|  | legendModel.get('pageIcons', true)[legendModel.getOrient().name][iconIdx], | 
|  | { | 
|  | // Buttons will be created in each render, so we do not need | 
|  | // to worry about avoiding using legendModel kept in scope. | 
|  | onclick: zrUtil.bind( | 
|  | self._pageGo, self, pageDataIndexName, legendModel, api | 
|  | ) | 
|  | }, | 
|  | { | 
|  | x: -pageIconSizeArr[0] / 2, | 
|  | y: -pageIconSizeArr[1] / 2, | 
|  | width: pageIconSizeArr[0], | 
|  | height: pageIconSizeArr[1] | 
|  | } | 
|  | ); | 
|  | icon.name = name; | 
|  | controllerGroup.add(icon); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @override | 
|  | */ | 
|  | layoutInner( | 
|  | legendModel: ScrollableLegendModel, | 
|  | itemAlign: ScrollableLegendOption['align'], | 
|  | maxSize: { width: number, height: number }, | 
|  | isFirstRender: boolean, | 
|  | selector: LegendSelectorButtonOption[], | 
|  | selectorPosition: ScrollableLegendOption['selectorPosition'] | 
|  | ) { | 
|  | const selectorGroup = this.getSelectorGroup(); | 
|  |  | 
|  | const orientIdx = legendModel.getOrient().index; | 
|  | const wh = WH[orientIdx]; | 
|  | const xy = XY[orientIdx]; | 
|  | const hw = WH[1 - orientIdx]; | 
|  | const yx = XY[1 - orientIdx]; | 
|  |  | 
|  | selector && layoutUtil.box( | 
|  | // Buttons in selectorGroup always layout horizontally | 
|  | 'horizontal', | 
|  | selectorGroup, | 
|  | legendModel.get('selectorItemGap', true) | 
|  | ); | 
|  |  | 
|  | const selectorButtonGap = legendModel.get('selectorButtonGap', true); | 
|  | const selectorRect = selectorGroup.getBoundingRect(); | 
|  | const selectorPos = [-selectorRect.x, -selectorRect.y]; | 
|  |  | 
|  | const processMaxSize = zrUtil.clone(maxSize); | 
|  | selector && (processMaxSize[wh] = maxSize[wh] - selectorRect[wh] - selectorButtonGap); | 
|  |  | 
|  | const mainRect = this._layoutContentAndController(legendModel, isFirstRender, | 
|  | processMaxSize, orientIdx, wh, hw, yx, xy | 
|  | ); | 
|  |  | 
|  | if (selector) { | 
|  | if (selectorPosition === 'end') { | 
|  | selectorPos[orientIdx] += mainRect[wh] + selectorButtonGap; | 
|  | } | 
|  | else { | 
|  | const offset = selectorRect[wh] + selectorButtonGap; | 
|  | selectorPos[orientIdx] -= offset; | 
|  | mainRect[xy] -= offset; | 
|  | } | 
|  | mainRect[wh] += selectorRect[wh] + selectorButtonGap; | 
|  |  | 
|  | selectorPos[1 - orientIdx] += mainRect[yx] + mainRect[hw] / 2 - selectorRect[hw] / 2; | 
|  | mainRect[hw] = Math.max(mainRect[hw], selectorRect[hw]); | 
|  | mainRect[yx] = Math.min(mainRect[yx], selectorRect[yx] + selectorPos[1 - orientIdx]); | 
|  |  | 
|  | selectorGroup.x = selectorPos[0]; | 
|  | selectorGroup.y = selectorPos[1]; | 
|  | selectorGroup.markRedraw(); | 
|  | } | 
|  |  | 
|  | return mainRect; | 
|  | } | 
|  |  | 
|  | _layoutContentAndController( | 
|  | legendModel: ScrollableLegendModel, | 
|  | isFirstRender: boolean, | 
|  | maxSize: { width: number, height: number }, | 
|  | orientIdx: 0 | 1, | 
|  | wh: 'width' | 'height', | 
|  | hw: 'width' | 'height', | 
|  | yx: 'x' | 'y', | 
|  | xy: 'y' | 'x' | 
|  | ) { | 
|  | const contentGroup = this.getContentGroup(); | 
|  | const containerGroup = this._containerGroup; | 
|  | const controllerGroup = this._controllerGroup; | 
|  |  | 
|  | // Place items in contentGroup. | 
|  | layoutUtil.box( | 
|  | legendModel.get('orient'), | 
|  | contentGroup, | 
|  | legendModel.get('itemGap'), | 
|  | !orientIdx ? null : maxSize.width, | 
|  | orientIdx ? null : maxSize.height | 
|  | ); | 
|  |  | 
|  | layoutUtil.box( | 
|  | // Buttons in controller are layout always horizontally. | 
|  | 'horizontal', | 
|  | controllerGroup, | 
|  | legendModel.get('pageButtonItemGap', true) | 
|  | ); | 
|  |  | 
|  | const contentRect = contentGroup.getBoundingRect(); | 
|  | const controllerRect = controllerGroup.getBoundingRect(); | 
|  | const showController = this._showController = contentRect[wh] > maxSize[wh]; | 
|  |  | 
|  | // In case that the inner elements of contentGroup layout do not based on [0, 0] | 
|  | const contentPos = [-contentRect.x, -contentRect.y]; | 
|  | // Remain contentPos when scroll animation perfroming. | 
|  | // If first rendering, `contentGroup.position` is [0, 0], which | 
|  | // does not make sense and may cause unexepcted animation if adopted. | 
|  | if (!isFirstRender) { | 
|  | contentPos[orientIdx] = contentGroup[xy]; | 
|  | } | 
|  |  | 
|  | // Layout container group based on 0. | 
|  | const containerPos = [0, 0]; | 
|  | const controllerPos = [-controllerRect.x, -controllerRect.y]; | 
|  | const pageButtonGap = zrUtil.retrieve2( | 
|  | legendModel.get('pageButtonGap', true), legendModel.get('itemGap', true) | 
|  | ); | 
|  |  | 
|  | // Place containerGroup and controllerGroup and contentGroup. | 
|  | if (showController) { | 
|  | const pageButtonPosition = legendModel.get('pageButtonPosition', true); | 
|  | // controller is on the right / bottom. | 
|  | if (pageButtonPosition === 'end') { | 
|  | controllerPos[orientIdx] += maxSize[wh] - controllerRect[wh]; | 
|  | } | 
|  | // controller is on the left / top. | 
|  | else { | 
|  | containerPos[orientIdx] += controllerRect[wh] + pageButtonGap; | 
|  | } | 
|  | } | 
|  |  | 
|  | // Always align controller to content as 'middle'. | 
|  | controllerPos[1 - orientIdx] += contentRect[hw] / 2 - controllerRect[hw] / 2; | 
|  |  | 
|  | contentGroup.setPosition(contentPos); | 
|  | containerGroup.setPosition(containerPos); | 
|  | controllerGroup.setPosition(controllerPos); | 
|  |  | 
|  | // Calculate `mainRect` and set `clipPath`. | 
|  | // mainRect should not be calculated by `this.group.getBoundingRect()` | 
|  | // for sake of the overflow. | 
|  | const mainRect = {x: 0, y: 0} as ZRRectLike; | 
|  |  | 
|  | // Consider content may be overflow (should be clipped). | 
|  | mainRect[wh] = showController ? maxSize[wh] : contentRect[wh]; | 
|  | mainRect[hw] = Math.max(contentRect[hw], controllerRect[hw]); | 
|  |  | 
|  | // `containerRect[yx] + containerPos[1 - orientIdx]` is 0. | 
|  | mainRect[yx] = Math.min(0, controllerRect[yx] + controllerPos[1 - orientIdx]); | 
|  |  | 
|  | containerGroup.__rectSize = maxSize[wh]; | 
|  | if (showController) { | 
|  | const clipShape = {x: 0, y: 0} as graphic.Rect['shape']; | 
|  | clipShape[wh] = Math.max(maxSize[wh] - controllerRect[wh] - pageButtonGap, 0); | 
|  | clipShape[hw] = mainRect[hw]; | 
|  | containerGroup.setClipPath(new graphic.Rect({shape: clipShape})); | 
|  | // Consider content may be larger than container, container rect | 
|  | // can not be obtained from `containerGroup.getBoundingRect()`. | 
|  | containerGroup.__rectSize = clipShape[wh]; | 
|  | } | 
|  | else { | 
|  | // Do not remove or ignore controller. Keep them set as placeholders. | 
|  | controllerGroup.eachChild(function (child: Displayable) { | 
|  | child.attr({ | 
|  | invisible: true, | 
|  | silent: true | 
|  | }); | 
|  | }); | 
|  | } | 
|  |  | 
|  | // Content translate animation. | 
|  | const pageInfo = this._getPageInfo(legendModel); | 
|  | pageInfo.pageIndex != null && graphic.updateProps( | 
|  | contentGroup, | 
|  | { x: pageInfo.contentPosition[0], y: pageInfo.contentPosition[1] }, | 
|  | // When switch from "show controller" to "not show controller", view should be | 
|  | // updated immediately without animation, otherwise causes weird effect. | 
|  | showController ? legendModel : null | 
|  | ); | 
|  |  | 
|  | this._updatePageInfoView(legendModel, pageInfo); | 
|  |  | 
|  | return mainRect; | 
|  | } | 
|  |  | 
|  | _pageGo( | 
|  | to: 'pagePrevDataIndex' | 'pageNextDataIndex', | 
|  | legendModel: ScrollableLegendModel, | 
|  | api: ExtensionAPI | 
|  | ) { | 
|  | const scrollDataIndex = this._getPageInfo(legendModel)[to]; | 
|  |  | 
|  | scrollDataIndex != null && api.dispatchAction({ | 
|  | type: 'legendScroll', | 
|  | scrollDataIndex: scrollDataIndex, | 
|  | legendId: legendModel.id | 
|  | }); | 
|  | } | 
|  |  | 
|  | _updatePageInfoView( | 
|  | legendModel: ScrollableLegendModel, | 
|  | pageInfo: PageInfo | 
|  | ) { | 
|  | const controllerGroup = this._controllerGroup; | 
|  |  | 
|  | zrUtil.each(['pagePrev', 'pageNext'], function (name) { | 
|  | const key = (name + 'DataIndex') as'pagePrevDataIndex' | 'pageNextDataIndex'; | 
|  | const canJump = pageInfo[key] != null; | 
|  | const icon = controllerGroup.childOfName(name) as graphic.Path; | 
|  | if (icon) { | 
|  | icon.setStyle( | 
|  | 'fill', | 
|  | canJump | 
|  | ? legendModel.get('pageIconColor', true) | 
|  | : legendModel.get('pageIconInactiveColor', true) | 
|  | ); | 
|  | icon.cursor = canJump ? 'pointer' : 'default'; | 
|  | } | 
|  | }); | 
|  |  | 
|  | const pageText = controllerGroup.childOfName('pageText') as graphic.Text; | 
|  | const pageFormatter = legendModel.get('pageFormatter'); | 
|  | const pageIndex = pageInfo.pageIndex; | 
|  | const current = pageIndex != null ? pageIndex + 1 : 0; | 
|  | const total = pageInfo.pageCount; | 
|  |  | 
|  | pageText && pageFormatter && pageText.setStyle( | 
|  | 'text', | 
|  | zrUtil.isString(pageFormatter) | 
|  | ? pageFormatter.replace('{current}', current == null ? '' : current + '') | 
|  | .replace('{total}', total == null ? '' : total + '') | 
|  | : pageFormatter({current: current, total: total}) | 
|  | ); | 
|  | } | 
|  |  | 
|  | /** | 
|  | *  contentPosition: Array.<number>, null when data item not found. | 
|  | *  pageIndex: number, null when data item not found. | 
|  | *  pageCount: number, always be a number, can be 0. | 
|  | *  pagePrevDataIndex: number, null when no previous page. | 
|  | *  pageNextDataIndex: number, null when no next page. | 
|  | * } | 
|  | */ | 
|  | _getPageInfo(legendModel: ScrollableLegendModel): PageInfo { | 
|  | const scrollDataIndex = legendModel.get('scrollDataIndex', true); | 
|  | const contentGroup = this.getContentGroup(); | 
|  | const containerRectSize = this._containerGroup.__rectSize; | 
|  | const orientIdx = legendModel.getOrient().index; | 
|  | const wh = WH[orientIdx]; | 
|  | const xy = XY[orientIdx]; | 
|  |  | 
|  | const targetItemIndex = this._findTargetItemIndex(scrollDataIndex); | 
|  | const children = contentGroup.children(); | 
|  | const targetItem = children[targetItemIndex]; | 
|  | const itemCount = children.length; | 
|  | const pCount = !itemCount ? 0 : 1; | 
|  |  | 
|  | const result: PageInfo = { | 
|  | contentPosition: [contentGroup.x, contentGroup.y], | 
|  | pageCount: pCount, | 
|  | pageIndex: pCount - 1, | 
|  | pagePrevDataIndex: null, | 
|  | pageNextDataIndex: null | 
|  | }; | 
|  |  | 
|  | if (!targetItem) { | 
|  | return result; | 
|  | } | 
|  |  | 
|  | const targetItemInfo = getItemInfo(targetItem); | 
|  | result.contentPosition[orientIdx] = -targetItemInfo.s; | 
|  |  | 
|  | // Strategy: | 
|  | // (1) Always align based on the left/top most item. | 
|  | // (2) It is user-friendly that the last item shown in the | 
|  | // current window is shown at the begining of next window. | 
|  | // Otherwise if half of the last item is cut by the window, | 
|  | // it will have no chance to display entirely. | 
|  | // (3) Consider that item size probably be different, we | 
|  | // have calculate pageIndex by size rather than item index, | 
|  | // and we can not get page index directly by division. | 
|  | // (4) The window is to narrow to contain more than | 
|  | // one item, we should make sure that the page can be fliped. | 
|  |  | 
|  | for (let i = targetItemIndex + 1, | 
|  | winStartItemInfo = targetItemInfo, | 
|  | winEndItemInfo = targetItemInfo, | 
|  | currItemInfo = null; | 
|  | i <= itemCount; | 
|  | ++i | 
|  | ) { | 
|  | currItemInfo = getItemInfo(children[i]); | 
|  | if ( | 
|  | // Half of the last item is out of the window. | 
|  | (!currItemInfo && winEndItemInfo.e > winStartItemInfo.s + containerRectSize) | 
|  | // If the current item does not intersect with the window, the new page | 
|  | // can be started at the current item or the last item. | 
|  | || (currItemInfo && !intersect(currItemInfo, winStartItemInfo.s)) | 
|  | ) { | 
|  | if (winEndItemInfo.i > winStartItemInfo.i) { | 
|  | winStartItemInfo = winEndItemInfo; | 
|  | } | 
|  | else { // e.g., when page size is smaller than item size. | 
|  | winStartItemInfo = currItemInfo; | 
|  | } | 
|  | if (winStartItemInfo) { | 
|  | if (result.pageNextDataIndex == null) { | 
|  | result.pageNextDataIndex = winStartItemInfo.i; | 
|  | } | 
|  | ++result.pageCount; | 
|  | } | 
|  | } | 
|  | winEndItemInfo = currItemInfo; | 
|  | } | 
|  |  | 
|  | for (let i = targetItemIndex - 1, | 
|  | winStartItemInfo = targetItemInfo, | 
|  | winEndItemInfo = targetItemInfo, | 
|  | currItemInfo = null; | 
|  | i >= -1; | 
|  | --i | 
|  | ) { | 
|  | currItemInfo = getItemInfo(children[i]); | 
|  | if ( | 
|  | // If the the end item does not intersect with the window started | 
|  | // from the current item, a page can be settled. | 
|  | (!currItemInfo || !intersect(winEndItemInfo, currItemInfo.s)) | 
|  | // e.g., when page size is smaller than item size. | 
|  | && winStartItemInfo.i < winEndItemInfo.i | 
|  | ) { | 
|  | winEndItemInfo = winStartItemInfo; | 
|  | if (result.pagePrevDataIndex == null) { | 
|  | result.pagePrevDataIndex = winStartItemInfo.i; | 
|  | } | 
|  | ++result.pageCount; | 
|  | ++result.pageIndex; | 
|  | } | 
|  | winStartItemInfo = currItemInfo; | 
|  | } | 
|  |  | 
|  | return result; | 
|  |  | 
|  | function getItemInfo(el: Element): ItemInfo { | 
|  | if (el) { | 
|  | const itemRect = el.getBoundingRect(); | 
|  | const start = itemRect[xy] + el[xy]; | 
|  | return { | 
|  | s: start, | 
|  | e: start + itemRect[wh], | 
|  | i: (el as LegendItemElement).__legendDataIndex | 
|  | }; | 
|  | } | 
|  | } | 
|  |  | 
|  | function intersect(itemInfo: ItemInfo, winStart: number) { | 
|  | return itemInfo.e >= winStart && itemInfo.s <= winStart + containerRectSize; | 
|  | } | 
|  | } | 
|  |  | 
|  | _findTargetItemIndex(targetDataIndex: number) { | 
|  | if (!this._showController) { | 
|  | return 0; | 
|  | } | 
|  |  | 
|  | let index; | 
|  | const contentGroup = this.getContentGroup(); | 
|  | let defaultIndex: number; | 
|  |  | 
|  | contentGroup.eachChild(function (child, idx) { | 
|  | const legendDataIdx = (child as LegendItemElement).__legendDataIndex; | 
|  | // FIXME | 
|  | // If the given targetDataIndex (from model) is illegal, | 
|  | // we use defaultIndex. But the index on the legend model and | 
|  | // action payload is still illegal. That case will not be | 
|  | // changed until some scenario requires. | 
|  | if (defaultIndex == null && legendDataIdx != null) { | 
|  | defaultIndex = idx; | 
|  | } | 
|  | if (legendDataIdx === targetDataIndex) { | 
|  | index = idx; | 
|  | } | 
|  | }); | 
|  |  | 
|  | return index != null ? index : defaultIndex; | 
|  | } | 
|  | } | 
|  |  | 
|  | export default ScrollableLegendView; |