/
githubmirror
/
echarts
Обзор
Документация
Войти
/
githubmirror
/
echarts
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
6.0.0
src/util/layout.ts
773 строки
27 KB
Dirk Stolle
chore: fix typos
27 июн 2025, 13:49
27 июн 2025, 13:49
5ad87d4
Код
Авторство
О чём код?
/* * 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. */ // Layout helpers for each component positioning import * as zrUtil from 'zrender/src/core/util'; import BoundingRect, { RectLike } from 'zrender/src/core/BoundingRect'; import {parsePercent} from './number'; import * as formatUtil from './format'; import { BoxLayoutOptionMixin, CircleLayoutOptionMixin, NullUndefined, ComponentLayoutMode, SeriesOption, PreserveAspectMixin, ComponentOption } from './types'; import Group from 'zrender/src/graphic/Group'; import { SectorShape } from 'zrender/src/graphic/shape/Sector'; import Element from 'zrender/src/Element'; import { Dictionary } from 'zrender/src/core/types'; import ExtensionAPI from '../core/ExtensionAPI'; import { error } from './log'; import { BoxCoordinateSystemCoordFrom, getCoordForBoxCoordSys } from '../core/CoordinateSystem'; import SeriesModel from '../model/Series'; import type Model from '../model/Model'; import type ComponentModel from '../model/Component'; const each = zrUtil.each; /** * @see {getLayoutRect} */ export interface LayoutRect extends BoundingRect { margin: number[] } export interface NewlineElement extends Element { newline: boolean } type BoxLayoutKeys = keyof BoxLayoutOptionMixin; /** * @public */ export const LOCATION_PARAMS = [ 'left', 'right', 'top', 'bottom', 'width', 'height' ] as const; /** * @public */ export const HV_NAMES = [ ['width', 'left', 'right'], ['height', 'top', 'bottom'] ] as const; function boxLayout( orient: 'horizontal' | 'vertical', group: Group, gap: number, maxWidth?: number, maxHeight?: number ) { let x = 0; let y = 0; if (maxWidth == null) { maxWidth = Infinity; } if (maxHeight == null) { maxHeight = Infinity; } let currentLineMaxSize = 0; group.eachChild(function (child, idx) { const rect = child.getBoundingRect(); const nextChild = group.childAt(idx + 1); const nextChildRect = nextChild && nextChild.getBoundingRect(); let nextX: number; let nextY: number; if (orient === 'horizontal') { const moveX = rect.width + (nextChildRect ? (-nextChildRect.x + rect.x) : 0); nextX = x + moveX; // Wrap when width exceeds maxWidth or meet a `newline` group // FIXME compare before adding gap? if (nextX > maxWidth || (child as NewlineElement).newline) { x = 0; nextX = moveX; y += currentLineMaxSize + gap; currentLineMaxSize = rect.height; } else { // FIXME: consider rect.y is not `0`? currentLineMaxSize = Math.max(currentLineMaxSize, rect.height); } } else { const moveY = rect.height + (nextChildRect ? (-nextChildRect.y + rect.y) : 0); nextY = y + moveY; // Wrap when width exceeds maxHeight or meet a `newline` group if (nextY > maxHeight || (child as NewlineElement).newline) { x += currentLineMaxSize + gap; y = 0; nextY = moveY; currentLineMaxSize = rect.width; } else { currentLineMaxSize = Math.max(currentLineMaxSize, rect.width); } } if ((child as NewlineElement).newline) { return; } child.x = x; child.y = y; child.markRedraw(); orient === 'horizontal' ? (x = nextX + gap) : (y = nextY + gap); }); } /** * VBox or HBox layouting * @param {string} orient * @param {module:zrender/graphic/Group} group * @param {number} gap * @param {number} [width=Infinity] * @param {number} [height=Infinity] */ export const box = boxLayout; /** * VBox layouting * @param {module:zrender/graphic/Group} group * @param {number} gap * @param {number} [width=Infinity] * @param {number} [height=Infinity] */ export const vbox = zrUtil.curry(boxLayout, 'vertical'); /** * HBox layouting * @param {module:zrender/graphic/Group} group * @param {number} gap * @param {number} [width=Infinity] * @param {number} [height=Infinity] */ export const hbox = zrUtil.curry(boxLayout, 'horizontal'); export function getBoxLayoutParams(boxLayoutModel: Model<BoxLayoutOptionMixin>, ignoreParent: boolean) { return { left: boxLayoutModel.getShallow('left', ignoreParent), top: boxLayoutModel.getShallow('top', ignoreParent), right: boxLayoutModel.getShallow('right', ignoreParent), bottom: boxLayoutModel.getShallow('bottom', ignoreParent), width: boxLayoutModel.getShallow('width', ignoreParent), height: boxLayoutModel.getShallow('height', ignoreParent) }; } type CircleLayoutSeriesOption = SeriesOption & CircleLayoutOptionMixin<{ // `center: string | number` has been accepted in series.pie. centerExtra: string | number }>; function getViewRectAndCenterForCircleLayout<TOption extends CircleLayoutSeriesOption>( seriesModel: SeriesModel<TOption>, api: ExtensionAPI ) { const layoutRef = createBoxLayoutReference(seriesModel, api, { enableLayoutOnlyByCenter: true, }); const boxLayoutParams = seriesModel.getBoxLayoutParams(); let viewRect: LayoutRect; let center: number[]; if (layoutRef.type === BoxLayoutReferenceType.point) { center = layoutRef.refPoint; // `viewRect` is required in `pie/labelLayout.ts`. viewRect = getLayoutRect( boxLayoutParams, {width: api.getWidth(), height: api.getHeight()} ); } else { // layoutRef.type === layout.BoxLayoutReferenceType.rect const centerOption = seriesModel.get('center'); const centerOptionArr = zrUtil.isArray(centerOption) ? centerOption : [centerOption, centerOption]; viewRect = getLayoutRect( boxLayoutParams, layoutRef.refContainer ); center = layoutRef.boxCoordFrom === BoxCoordinateSystemCoordFrom.coord2 ? layoutRef.refPoint // option `series.center` has been used as coord. : [ parsePercent(centerOptionArr[0], viewRect.width) + viewRect.x, parsePercent(centerOptionArr[1], viewRect.height) + viewRect.y, ]; } return {viewRect, center}; } export function getCircleLayout<TOption extends CircleLayoutSeriesOption>( seriesModel: SeriesModel<TOption>, api: ExtensionAPI ): Pick<SectorShape, 'cx' | 'cy' | 'r' | 'r0'> & {viewRect: LayoutRect} { // center can be string or number when coordinateSystem is specified const {viewRect, center} = getViewRectAndCenterForCircleLayout(seriesModel, api); let radius = seriesModel.get('radius'); if (!zrUtil.isArray(radius)) { radius = [0, radius]; } const width = parsePercent(viewRect.width, api.getWidth()); const height = parsePercent(viewRect.height, api.getHeight()); const size = Math.min(width, height); const r0 = parsePercent(radius[0], size / 2); const r = parsePercent(radius[1], size / 2); return { cx: center[0], cy: center[1], r0, r, viewRect, }; } type GetLayoutRectInputContainerRect = { x?: number, // 0 by default y?: number, // 0 by default width: number, height: number, }; /** * Parse position info. */ export function getLayoutRect( positionInfo: BoxLayoutOptionMixin & { // PENDING: // when width can not be decided but height can be decided and aspect is near Infinity, // or when height can not be decided but width can be decided and aspect is near 0, // the result width or height is near Inifity. It's logically correct, therefore // currently we do not handle it, until bad cases arise. // // aspect is width / height. But this method does not preserve aspect ratio if // both width and height can be decided by the given left/top/bottom/right/width/height. // To always preserve aspect ratio, uses `applyPreserveAspect` to process the result. aspect?: number }, containerRect: GetLayoutRectInputContainerRect, // This is the space from the `containerRect` to the returned bounding rect. // Commonly used in option `legend.padding`, `timeline.padding`, `title.padding`, // `visualMap.padding`, ... // [NOTICE]: // It's named `margin`, because it's the space that outside the bounding rect. But from // the perspective of the the caller, it's commonly used as the `padding` of a component, // because conventionally background color covers this space. // [BEHAVIOR]: // - If width/height is specified, `margin` does not effect them. // - Otherwise, they are calculated based on the rect that `containerRect` shrinked by `margin`. // - left/right/top/bottom are based on the rect that `containerRect` shrinked by `margin`. margin?: number | number[] ): LayoutRect { margin = formatUtil.normalizeCssArray(margin || 0); const containerWidth = containerRect.width; const containerHeight = containerRect.height; let left = parsePercent(positionInfo.left, containerWidth); let top = parsePercent(positionInfo.top, containerHeight); const right = parsePercent(positionInfo.right, containerWidth); const bottom = parsePercent(positionInfo.bottom, containerHeight); let width = parsePercent(positionInfo.width, containerWidth); let height = parsePercent(positionInfo.height, containerHeight); const verticalMargin = margin[2] + margin[0]; const horizontalMargin = margin[1] + margin[3]; const aspect = positionInfo.aspect; // If width is not specified, calculate width from left and right if (isNaN(width)) { width = containerWidth - right - horizontalMargin - left; } if (isNaN(height)) { height = containerHeight - bottom - verticalMargin - top; } if (aspect != null) { // If width and height are not given // 1. Graph should not exceeds the container // 2. Aspect must be keeped // 3. Graph should take the space as more as possible // FIXME // Margin is not considered, because there is no case that both // using margin and aspect so far. if (isNaN(width) && isNaN(height)) { // PENDING: if only `left` or `right` is defined, perhaps it's more preferable to // calculate size based on `containerWidth - left` or `containerWidth - left` here, // but for backward compatibility we do not change it. if (aspect > containerWidth / containerHeight) { width = containerWidth * 0.8; } else { height = containerHeight * 0.8; } } // Calculate width or height with given aspect if (isNaN(width)) { width = aspect * height; } if (isNaN(height)) { height = width / aspect; } } // If left is not specified, calculate left from right and width if (isNaN(left)) { left = containerWidth - right - width - horizontalMargin; } if (isNaN(top)) { top = containerHeight - bottom - height - verticalMargin; } // Align left and top switch (positionInfo.left || positionInfo.right) { case 'center': left = containerWidth / 2 - width / 2 - margin[3]; break; case 'right': left = containerWidth - width - horizontalMargin; break; } switch (positionInfo.top || positionInfo.bottom) { case 'middle': case 'center': top = containerHeight / 2 - height / 2 - margin[0]; break; case 'bottom': top = containerHeight - height - verticalMargin; break; } // If something is wrong and left, top, width, height are calculated as NaN left = left || 0; top = top || 0; if (isNaN(width)) { // Width may be NaN if only one value is given except width width = containerWidth - horizontalMargin - left - (right || 0); } if (isNaN(height)) { // Height may be NaN if only one value is given except height height = containerHeight - verticalMargin - top - (bottom || 0); } const rect = new BoundingRect( (containerRect.x || 0) + left + margin[3], (containerRect.y || 0) + top + margin[0], width, height ) as LayoutRect; rect.margin = margin; return rect; } /** * PENDING: * when preserveAspect: 'cover' and aspect is near Infinity * or when preserveAspect: 'contain' and aspect is near 0, * the result width or height is near Inifity. It's logically correct, * Therefore currently we do not handle it, until bad cases arise. */ export function applyPreserveAspect( component: ComponentModel<ComponentOption & PreserveAspectMixin>, layoutRect: LayoutRect, // That is, `width / height`. // Assume `aspect` is positive. aspect: number, ): LayoutRect { const preserveAspect = component.getShallow('preserveAspect', true); if (!preserveAspect) { return layoutRect; } const actualAspect = layoutRect.width / layoutRect.height; if (Math.abs(Math.atan(aspect) - Math.atan(actualAspect)) < 1e-9) { return layoutRect; } const preserveAspectAlign = component.getShallow('preserveAspectAlign', true); const preserveAspectVerticalAlign = component.getShallow('preserveAspectVerticalAlign', true); const layoutOptInner: BoxLayoutOptionMixin = {width: layoutRect.width, height: layoutRect.height}; const isCover = preserveAspect === 'cover'; if ((actualAspect > aspect && !isCover) || (actualAspect < aspect && isCover)) { layoutOptInner.width = layoutRect.height * aspect; preserveAspectAlign === 'left' ? (layoutOptInner.left = 0) : preserveAspectAlign === 'right' ? (layoutOptInner.right = 0) : (layoutOptInner.left = 'center'); } else { layoutOptInner.height = layoutRect.width / aspect; preserveAspectVerticalAlign === 'top' ? (layoutOptInner.top = 0) : preserveAspectVerticalAlign === 'bottom' ? (layoutOptInner.bottom = 0) : (layoutOptInner.top = 'middle'); } return getLayoutRect(layoutOptInner, layoutRect); } type CreateBoxLayoutReferenceOpt<TEnableByCenter extends boolean = false> = { // Use this only if: // - Intending to layout based on coord sys that can not get a rect from `dataToLayout`. // - Can layout based only on center but a rect is not essential, such as pie, chord. enableLayoutOnlyByCenter?: TEnableByCenter; }; export const BoxLayoutReferenceType = { rect: 1, point: 2, } as const; export type BoxLayoutReferenceType = (typeof BoxLayoutReferenceType)[keyof typeof BoxLayoutReferenceType]; export type BoxLayoutReferenceResult<TEnableByCenter extends boolean = false> = TEnableByCenter extends true ? (BoxLayoutReferenceRectResult | BoxLayoutReferencePointResult) : BoxLayoutReferenceRectResult; type BoxLayoutReferenceRectResult = { // This is the default way. type: typeof BoxLayoutReferenceType.rect; refContainer: LayoutRect; refPoint: number[]; // The center of rect in this case. boxCoordFrom: BoxCoordinateSystemCoordFrom | NullUndefined; }; type BoxLayoutReferencePointResult = { // This is available only if `enableLayoutOnlyByCenter: true` // and `layoutRect` is not available. type: typeof BoxLayoutReferenceType.point; refPoint: number[]; boxCoordFrom: BoxCoordinateSystemCoordFrom | NullUndefined; }; /** * Uniformly calculate layout reference (rect or center) based on either: * - viewport: * - Get `refContainer` as `{x: 0, y: 0, width: api.getWidth(), height: api.getHeight()}` * - coordinate system, which can serve in several ways: * - Use `dataToPoint` to get the `refPoint`, such as, in cartesian2d coord sys. * - Use `dataToLayout` to get the `refContainer`, such as, in matrix coord sys. */ export function createBoxLayoutReference<TEnableByCenter extends boolean = false>( model: ComponentModel, api: ExtensionAPI, opt?: CreateBoxLayoutReferenceOpt<TEnableByCenter> ): BoxLayoutReferenceResult<TEnableByCenter> { let refContainer: RectLike | NullUndefined; let refPoint: number[] | NullUndefined; let layoutRefType: BoxLayoutReferenceType | NullUndefined; const boxCoordSys = model.boxCoordinateSystem; let boxCoordFrom: BoxCoordinateSystemCoordFrom | NullUndefined; if (boxCoordSys) { const {coord, from} = getCoordForBoxCoordSys(model); // Do not use `clamp` in `dataToLayout` and `dataToPoint`, because: // 1. Should support overflow (such as, by dataZoom), where NaN should be in the result. // 2. Be consistent with the way used in `series.data` if (boxCoordSys.dataToLayout) { layoutRefType = BoxLayoutReferenceType.rect; boxCoordFrom = from; const result = boxCoordSys.dataToLayout(coord); refContainer = result.contentRect || result.rect; } else if (opt && opt.enableLayoutOnlyByCenter && boxCoordSys.dataToPoint) { layoutRefType = BoxLayoutReferenceType.point; boxCoordFrom = from; refPoint = boxCoordSys.dataToPoint(coord); } else { if (__DEV__) { error(`${model.type}[${model.componentIndex}]` + ` layout based on ${boxCoordSys.type} is not supported.` ); } } } if (layoutRefType == null) { layoutRefType = BoxLayoutReferenceType.rect; } if (layoutRefType === BoxLayoutReferenceType.rect) { if (!refContainer) { refContainer = {x: 0, y: 0, width: api.getWidth(), height: api.getHeight()}; } refPoint = [refContainer.x + refContainer.width / 2, refContainer.y + refContainer.height / 2]; } return {type: layoutRefType, refContainer, refPoint, boxCoordFrom} as BoxLayoutReferenceResult<TEnableByCenter>; } /** * Position a zr element in viewport * Group position is specified by either * {left, top}, {right, bottom} * If all properties exists, right and bottom will be igonred. * * Logic: * 1. Scale (against origin point in parent coord) * 2. Rotate (against origin point in parent coord) * 3. Translate (with el.position by this method) * So this method only fixes the last step 'Translate', which does not affect * scaling and rotating. * * If be called repeatedly with the same input el, the same result will be gotten. * * Return true if the layout happened. * * @param el Should have `getBoundingRect` method. * @param positionInfo * @param positionInfo.left * @param positionInfo.top * @param positionInfo.right * @param positionInfo.bottom * @param positionInfo.width Only for opt.boundingModel: 'raw' * @param positionInfo.height Only for opt.boundingModel: 'raw' * @param containerRect * @param margin * @param opt * @param opt.hv Only horizontal or only vertical. Default to be [1, 1] * @param opt.boundingMode * Specify how to calculate boundingRect when locating. * 'all': Position the boundingRect that is transformed and uioned * both itself and its descendants. * This mode simplies confine the elements in the bounding * of their container (e.g., using 'right: 0'). * 'raw': Position the boundingRect that is not transformed and only itself. * This mode is useful when you want a element can overflow its * container. (Consider a rotated circle needs to be located in a corner.) * In this mode positionInfo.width/height can only be number. */ export function positionElement( el: Element, positionInfo: BoxLayoutOptionMixin, containerRect: GetLayoutRectInputContainerRect, margin?: number[] | number, opt?: { hv: [1 | 0 | boolean, 1 | 0 | boolean], boundingMode: 'all' | 'raw' }, out?: { x?: number, y?: number } ): boolean { const h = !opt || !opt.hv || opt.hv[0]; const v = !opt || !opt.hv || opt.hv[1]; const boundingMode = opt && opt.boundingMode || 'all'; out = out || el; out.x = el.x; out.y = el.y; if (!h && !v) { return false; } let rect; if (boundingMode === 'raw') { rect = el.type === 'group' ? new BoundingRect(0, 0, +positionInfo.width || 0, +positionInfo.height || 0) : el.getBoundingRect(); } else { rect = el.getBoundingRect(); if (el.needLocalTransform()) { const transform = el.getLocalTransform(); // Notice: raw rect may be inner object of el, // which should not be modified. rect = rect.clone(); rect.applyTransform(transform); } } // The real width and height can not be specified but calculated by the given el. const layoutRect = getLayoutRect( zrUtil.defaults( {width: rect.width, height: rect.height}, positionInfo ), containerRect, margin ); // Because 'tranlate' is the last step in transform // (see zrender/core/Transformable#getLocalTransform), // we can just only modify el.position to get final result. const dx = h ? layoutRect.x - rect.x : 0; const dy = v ? layoutRect.y - rect.y : 0; if (boundingMode === 'raw') { out.x = dx; out.y = dy; } else { out.x += dx; out.y += dy; } if (out === el) { el.markRedraw(); } return true; } /** * @param option Contains some of the properties in HV_NAMES. * @param hvIdx 0: horizontal; 1: vertical. */ export function sizeCalculable(option: BoxLayoutOptionMixin, hvIdx: number): boolean { return option[HV_NAMES[hvIdx][0]] != null || (option[HV_NAMES[hvIdx][1]] != null && option[HV_NAMES[hvIdx][2]] != null); } export function fetchLayoutMode(ins: any): ComponentLayoutMode { const layoutMode = ins.layoutMode || ins.constructor.layoutMode; return zrUtil.isObject(layoutMode) ? layoutMode : layoutMode ? {type: layoutMode} : null; } /** * Consider Case: * When default option has {left: 0, width: 100}, and we set {right: 0} * through setOption or media query, using normal zrUtil.merge will cause * {right: 0} does not take effect. * * @example * ComponentModel.extend({ * init: function () { * ... * let inputPositionParams = layout.getLayoutParams(option); * this.mergeOption(inputPositionParams); * }, * mergeOption: function (newOption) { * newOption && zrUtil.merge(thisOption, newOption, true); * layout.mergeLayoutParam(thisOption, newOption); * } * }); * * @param targetOption * @param newOption * @param opt */ export function mergeLayoutParam<T extends BoxLayoutOptionMixin>( targetOption: T, newOption: T, opt?: ComponentLayoutMode ) { let ignoreSize = opt && opt.ignoreSize; !zrUtil.isArray(ignoreSize) && (ignoreSize = [ignoreSize, ignoreSize]); const hResult = merge(HV_NAMES[0], 0); const vResult = merge(HV_NAMES[1], 1); copy(HV_NAMES[0], targetOption, hResult); copy(HV_NAMES[1], targetOption, vResult); function merge(names: typeof HV_NAMES[number], hvIdx: number) { const newParams: BoxLayoutOptionMixin = {}; let newValueCount = 0; const merged: BoxLayoutOptionMixin = {}; let mergedValueCount = 0; const enoughParamNumber = 2; each(names, function (name: BoxLayoutKeys) { merged[name] = targetOption[name]; }); each(names, function (name: BoxLayoutKeys) { // Consider case: newOption.width is null, which is // set by user for removing width setting. zrUtil.hasOwn(newOption, name) && (newParams[name] = merged[name] = newOption[name]); hasValue(newParams, name) && newValueCount++; hasValue(merged, name) && mergedValueCount++; }); if ((ignoreSize as [boolean, boolean])[hvIdx]) { // Only one of left/right is premitted to exist. if (hasValue(newOption, names[1])) { merged[names[2]] = null; } else if (hasValue(newOption, names[2])) { merged[names[1]] = null; } return merged; } // Case: newOption: {width: ..., right: ...}, // or targetOption: {right: ...} and newOption: {width: ...}, // There is no conflict when merged only has params count // little than enoughParamNumber. if (mergedValueCount === enoughParamNumber || !newValueCount) { return merged; } // Case: newOption: {width: ..., right: ...}, // Than we can make sure user only want those two, and ignore // all origin params in targetOption. else if (newValueCount >= enoughParamNumber) { return newParams; } else { // Chose another param from targetOption by priority. for (let i = 0; i < names.length; i++) { const name = names[i]; if (!zrUtil.hasOwn(newParams, name) && zrUtil.hasOwn(targetOption, name)) { newParams[name] = targetOption[name]; break; } } return newParams; } } function hasValue(obj: Dictionary<any>, name: string): boolean { return obj[name] != null && obj[name] !== 'auto'; } function copy(names: readonly string[], target: Dictionary<any>, source: Dictionary<any>) { each(names, function (name) { target[name] = source[name]; }); } } /** * Retrieve 'left', 'right', 'top', 'bottom', 'width', 'height' from object. */ export function getLayoutParams(source: BoxLayoutOptionMixin): BoxLayoutOptionMixin { return copyLayoutParams({}, source); } /** * Retrieve 'left', 'right', 'top', 'bottom', 'width', 'height' from object. * @param {Object} source * @return {Object} Result contains those props. */ export function copyLayoutParams(target: BoxLayoutOptionMixin, source: BoxLayoutOptionMixin): BoxLayoutOptionMixin { source && target && each(LOCATION_PARAMS, function (name: BoxLayoutKeys) { zrUtil.hasOwn(source, name) && (target[name] = source[name]); }); return target; }