/* * 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 { Point, Path, Polyline } from '../util/graphic'; import PathProxy from 'zrender/src/core/PathProxy'; import { RectLike } from 'zrender/src/core/BoundingRect'; import { normalizeRadian } from 'zrender/src/contain/util'; import { cubicProjectPoint, quadraticProjectPoint } from 'zrender/src/core/curve'; import Element from 'zrender/src/Element'; import { defaults, retrieve2 } from 'zrender/src/core/util'; import { LabelLineOption, DisplayState, StatesOptionMixin } from '../util/types'; import Model from '../model/Model'; import { invert } from 'zrender/src/core/matrix'; import * as vector from 'zrender/src/core/vector'; import { DISPLAY_STATES, SPECIAL_STATES } from '../util/states'; const PI2 = Math.PI * 2; const CMD = PathProxy.CMD; const DEFAULT_SEARCH_SPACE = ['top', 'right', 'bottom', 'left'] as const; type CandidatePosition = typeof DEFAULT_SEARCH_SPACE[number]; function getCandidateAnchor( pos: CandidatePosition, distance: number, rect: RectLike, outPt: Point, outDir: Point ) { const width = rect.width; const height = rect.height; switch (pos) { case 'top': outPt.set( rect.x + width / 2, rect.y - distance ); outDir.set(0, -1); break; case 'bottom': outPt.set( rect.x + width / 2, rect.y + height + distance ); outDir.set(0, 1); break; case 'left': outPt.set( rect.x - distance, rect.y + height / 2 ); outDir.set(-1, 0); break; case 'right': outPt.set( rect.x + width + distance, rect.y + height / 2 ); outDir.set(1, 0); break; } } function projectPointToArc( cx: number, cy: number, r: number, startAngle: number, endAngle: number, anticlockwise: boolean, x: number, y: number, out: number[] ): number { x -= cx; y -= cy; const d = Math.sqrt(x * x + y * y); x /= d; y /= d; // Intersect point. const ox = x * r + cx; const oy = y * r + cy; if (Math.abs(startAngle - endAngle) % PI2 < 1e-4) { // Is a circle out[0] = ox; out[1] = oy; return d - r; } if (anticlockwise) { const tmp = startAngle; startAngle = normalizeRadian(endAngle); endAngle = normalizeRadian(tmp); } else { startAngle = normalizeRadian(startAngle); endAngle = normalizeRadian(endAngle); } if (startAngle > endAngle) { endAngle += PI2; } let angle = Math.atan2(y, x); if (angle < 0) { angle += PI2; } if ((angle >= startAngle && angle <= endAngle) || (angle + PI2 >= startAngle && angle + PI2 <= endAngle)) { // Project point is on the arc. out[0] = ox; out[1] = oy; return d - r; } const x1 = r * Math.cos(startAngle) + cx; const y1 = r * Math.sin(startAngle) + cy; const x2 = r * Math.cos(endAngle) + cx; const y2 = r * Math.sin(endAngle) + cy; const d1 = (x1 - x) * (x1 - x) + (y1 - y) * (y1 - y); const d2 = (x2 - x) * (x2 - x) + (y2 - y) * (y2 - y); if (d1 < d2) { out[0] = x1; out[1] = y1; return Math.sqrt(d1); } else { out[0] = x2; out[1] = y2; return Math.sqrt(d2); } } function projectPointToLine( x1: number, y1: number, x2: number, y2: number, x: number, y: number, out: number[], limitToEnds: boolean ) { const dx = x - x1; const dy = y - y1; let dx1 = x2 - x1; let dy1 = y2 - y1; const lineLen = Math.sqrt(dx1 * dx1 + dy1 * dy1); dx1 /= lineLen; dy1 /= lineLen; // dot product const projectedLen = dx * dx1 + dy * dy1; let t = projectedLen / lineLen; if (limitToEnds) { t = Math.min(Math.max(t, 0), 1); } t *= lineLen; const ox = out[0] = x1 + t * dx1; const oy = out[1] = y1 + t * dy1; return Math.sqrt((ox - x) * (ox - x) + (oy - y) * (oy - y)); } function projectPointToRect( x1: number, y1: number, width: number, height: number, x: number, y: number, out: number[] ): number { if (width < 0) { x1 = x1 + width; width = -width; } if (height < 0) { y1 = y1 + height; height = -height; } const x2 = x1 + width; const y2 = y1 + height; const ox = out[0] = Math.min(Math.max(x, x1), x2); const oy = out[1] = Math.min(Math.max(y, y1), y2); return Math.sqrt((ox - x) * (ox - x) + (oy - y) * (oy - y)); } const tmpPt: number[] = []; function nearestPointOnRect(pt: Point, rect: RectLike, out: Point) { const dist = projectPointToRect( rect.x, rect.y, rect.width, rect.height, pt.x, pt.y, tmpPt ); out.set(tmpPt[0], tmpPt[1]); return dist; } /** * Calculate min distance corresponding point. * This method won't evaluate if point is in the path. */ function nearestPointOnPath(pt: Point, path: PathProxy, out: Point) { let xi = 0; let yi = 0; let x0 = 0; let y0 = 0; let x1; let y1; let minDist = Infinity; const data = path.data; const x = pt.x; const y = pt.y; for (let i = 0; i < data.length;) { const cmd = data[i++]; if (i === 1) { xi = data[i]; yi = data[i + 1]; x0 = xi; y0 = yi; } let d = minDist; switch (cmd) { case CMD.M: // moveTo 命令重新创建一个新的 subpath, 并且更新新的起点 // 在 closePath 的时候使用 x0 = data[i++]; y0 = data[i++]; xi = x0; yi = y0; break; case CMD.L: d = projectPointToLine(xi, yi, data[i], data[i + 1], x, y, tmpPt, true); xi = data[i++]; yi = data[i++]; break; case CMD.C: d = cubicProjectPoint( xi, yi, data[i++], data[i++], data[i++], data[i++], data[i], data[i + 1], x, y, tmpPt ); xi = data[i++]; yi = data[i++]; break; case CMD.Q: d = quadraticProjectPoint( xi, yi, data[i++], data[i++], data[i], data[i + 1], x, y, tmpPt ); xi = data[i++]; yi = data[i++]; break; case CMD.A: // TODO Arc 判断的开销比较大 const cx = data[i++]; const cy = data[i++]; const rx = data[i++]; const ry = data[i++]; const theta = data[i++]; const dTheta = data[i++]; // TODO Arc 旋转 i += 1; const anticlockwise = !!(1 - data[i++]); x1 = Math.cos(theta) * rx + cx; y1 = Math.sin(theta) * ry + cy; // 不是直接使用 arc 命令 if (i <= 1) { // 第一个命令起点还未定义 x0 = x1; y0 = y1; } // zr 使用scale来模拟椭圆, 这里也对x做一定的缩放 const _x = (x - cx) * ry / rx + cx; d = projectPointToArc( cx, cy, ry, theta, theta + dTheta, anticlockwise, _x, y, tmpPt ); xi = Math.cos(theta + dTheta) * rx + cx; yi = Math.sin(theta + dTheta) * ry + cy; break; case CMD.R: x0 = xi = data[i++]; y0 = yi = data[i++]; const width = data[i++]; const height = data[i++]; d = projectPointToRect(x0, y0, width, height, x, y, tmpPt); break; case CMD.Z: d = projectPointToLine(xi, yi, x0, y0, x, y, tmpPt, true); xi = x0; yi = y0; break; } if (d < minDist) { minDist = d; out.set(tmpPt[0], tmpPt[1]); } } return minDist; } // Temporal variable for intermediate usage. const pt0 = new Point(); const pt1 = new Point(); const pt2 = new Point(); const dir = new Point(); const dir2 = new Point(); /** * Calculate a proper guide line based on the label position and graphic element definition * @param label * @param labelRect * @param target * @param targetRect */ export function updateLabelLinePoints( target: Element, labelLineModel: Model ) { if (!target) { return; } const labelLine = target.getTextGuideLine(); const label = target.getTextContent(); // Needs to create text guide in each charts. if (!(label && labelLine)) { return; } const labelGuideConfig = target.textGuideLineConfig || {}; const points = [[0, 0], [0, 0], [0, 0]]; const searchSpace = labelGuideConfig.candidates || DEFAULT_SEARCH_SPACE; const labelRect = label.getBoundingRect().clone(); labelRect.applyTransform(label.getComputedTransform()); let minDist = Infinity; const anchorPoint = labelGuideConfig.anchor; const targetTransform = target.getComputedTransform(); const targetInversedTransform = targetTransform && invert([], targetTransform); const len = labelLineModel.get('length2') || 0; if (anchorPoint) { pt2.copy(anchorPoint); } for (let i = 0; i < searchSpace.length; i++) { const candidate = searchSpace[i]; getCandidateAnchor(candidate, 0, labelRect, pt0, dir); Point.scaleAndAdd(pt1, pt0, dir, len); // Transform to target coord space. pt1.transform(targetInversedTransform); // Note: getBoundingRect will ensure the `path` being created. const boundingRect = target.getBoundingRect(); const dist = anchorPoint ? anchorPoint.distance(pt1) : (target instanceof Path ? nearestPointOnPath(pt1, target.path, pt2) : nearestPointOnRect(pt1, boundingRect, pt2)); // TODO pt2 is in the path if (dist < minDist) { minDist = dist; // Transform back to global space. pt1.transform(targetTransform); pt2.transform(targetTransform); pt2.toArray(points[0]); pt1.toArray(points[1]); pt0.toArray(points[2]); } } limitTurnAngle(points, labelLineModel.get('minTurnAngle')); labelLine.setShape({ points }); } // Temporal variable for the limitTurnAngle function const tmpArr: number[] = []; const tmpProjPoint = new Point(); /** * Reduce the line segment attached to the label to limit the turn angle between two segments. * @param linePoints * @param minTurnAngle Radian of minimum turn angle. 0 - 180 */ export function limitTurnAngle(linePoints: number[][], minTurnAngle: number) { if (!(minTurnAngle <= 180 && minTurnAngle > 0)) { return; } minTurnAngle = minTurnAngle / 180 * Math.PI; // The line points can be // /pt1----pt2 (label) // / // pt0/ pt0.fromArray(linePoints[0]); pt1.fromArray(linePoints[1]); pt2.fromArray(linePoints[2]); Point.sub(dir, pt0, pt1); Point.sub(dir2, pt2, pt1); const len1 = dir.len(); const len2 = dir2.len(); if (len1 < 1e-3 || len2 < 1e-3) { return; } dir.scale(1 / len1); dir2.scale(1 / len2); const angleCos = dir.dot(dir2); const minTurnAngleCos = Math.cos(minTurnAngle); if (minTurnAngleCos < angleCos) { // Smaller than minTurnAngle // Calculate project point of pt0 on pt1-pt2 const d = projectPointToLine(pt1.x, pt1.y, pt2.x, pt2.y, pt0.x, pt0.y, tmpArr, false); tmpProjPoint.fromArray(tmpArr); // Calculate new projected length with limited minTurnAngle and get the new connect point tmpProjPoint.scaleAndAdd(dir2, d / Math.tan(Math.PI - minTurnAngle)); // Limit the new calculated connect point between pt1 and pt2. const t = pt2.x !== pt1.x ? (tmpProjPoint.x - pt1.x) / (pt2.x - pt1.x) : (tmpProjPoint.y - pt1.y) / (pt2.y - pt1.y); if (isNaN(t)) { return; } if (t < 0) { Point.copy(tmpProjPoint, pt1); } else if (t > 1) { Point.copy(tmpProjPoint, pt2); } tmpProjPoint.toArray(linePoints[1]); } } /** * Limit the angle of line and the surface * @param maxSurfaceAngle Radian of minimum turn angle. 0 - 180. 0 is same direction to normal. 180 is opposite */ export function limitSurfaceAngle(linePoints: vector.VectorArray[], surfaceNormal: Point, maxSurfaceAngle: number) { if (!(maxSurfaceAngle <= 180 && maxSurfaceAngle > 0)) { return; } maxSurfaceAngle = maxSurfaceAngle / 180 * Math.PI; pt0.fromArray(linePoints[0]); pt1.fromArray(linePoints[1]); pt2.fromArray(linePoints[2]); Point.sub(dir, pt1, pt0); Point.sub(dir2, pt2, pt1); const len1 = dir.len(); const len2 = dir2.len(); if (len1 < 1e-3 || len2 < 1e-3) { return; } dir.scale(1 / len1); dir2.scale(1 / len2); const angleCos = dir.dot(surfaceNormal); const maxSurfaceAngleCos = Math.cos(maxSurfaceAngle); if (angleCos < maxSurfaceAngleCos) { // Calculate project point of pt0 on pt1-pt2 const d = projectPointToLine(pt1.x, pt1.y, pt2.x, pt2.y, pt0.x, pt0.y, tmpArr, false); tmpProjPoint.fromArray(tmpArr); const HALF_PI = Math.PI / 2; const angle2 = Math.acos(dir2.dot(surfaceNormal)); const newAngle = HALF_PI + angle2 - maxSurfaceAngle; if (newAngle >= HALF_PI) { // parallel Point.copy(tmpProjPoint, pt2); } else { // Calculate new projected length with limited minTurnAngle and get the new connect point tmpProjPoint.scaleAndAdd(dir2, d / Math.tan(Math.PI / 2 - newAngle)); // Limit the new calculated connect point between pt1 and pt2. const t = pt2.x !== pt1.x ? (tmpProjPoint.x - pt1.x) / (pt2.x - pt1.x) : (tmpProjPoint.y - pt1.y) / (pt2.y - pt1.y); if (isNaN(t)) { return; } if (t < 0) { Point.copy(tmpProjPoint, pt1); } else if (t > 1) { Point.copy(tmpProjPoint, pt2); } } tmpProjPoint.toArray(linePoints[1]); } } type LabelLineModel = Model; function setLabelLineState( labelLine: Polyline, ignore: boolean, stateName: string, stateModel: Model ) { const isNormal = stateName === 'normal'; const stateObj = isNormal ? labelLine : labelLine.ensureState(stateName); // Make sure display. stateObj.ignore = ignore; // Set smooth let smooth = stateModel.get('smooth'); if (smooth && smooth === true) { smooth = 0.3; } stateObj.shape = stateObj.shape || {}; if (smooth > 0) { (stateObj.shape as Polyline['shape']).smooth = smooth as number; } const styleObj = stateModel.getModel('lineStyle').getLineStyle(); isNormal ? labelLine.useStyle(styleObj) : stateObj.style = styleObj; } function buildLabelLinePath(path: CanvasRenderingContext2D, shape: Polyline['shape']) { const smooth = shape.smooth as number; const points = shape.points; if (!points) { return; } path.moveTo(points[0][0], points[0][1]); if (smooth > 0 && points.length >= 3) { const len1 = vector.dist(points[0], points[1]); const len2 = vector.dist(points[1], points[2]); if (!len1 || !len2) { path.lineTo(points[1][0], points[1][1]); path.lineTo(points[2][0], points[2][1]); return; } const moveLen = Math.min(len1, len2) * smooth; const midPoint0 = vector.lerp([], points[1], points[0], moveLen / len1); const midPoint2 = vector.lerp([], points[1], points[2], moveLen / len2); const midPoint1 = vector.lerp([], midPoint0, midPoint2, 0.5); path.bezierCurveTo(midPoint0[0], midPoint0[1], midPoint0[0], midPoint0[1], midPoint1[0], midPoint1[1]); path.bezierCurveTo(midPoint2[0], midPoint2[1], midPoint2[0], midPoint2[1], points[2][0], points[2][1]); } else { for (let i = 1; i < points.length; i++) { path.lineTo(points[i][0], points[i][1]); } } } /** * Create a label line if necessary and set it's style. */ export function setLabelLineStyle( targetEl: Element, statesModels: Record, defaultStyle?: Polyline['style'] ) { let labelLine = targetEl.getTextGuideLine(); const label = targetEl.getTextContent(); if (!label) { // Not show label line if there is no label. if (labelLine) { targetEl.removeTextGuideLine(); } return; } const normalModel = statesModels.normal; const showNormal = normalModel.get('show'); const labelIgnoreNormal = label.ignore; for (let i = 0; i < DISPLAY_STATES.length; i++) { const stateName = DISPLAY_STATES[i]; const stateModel = statesModels[stateName]; const isNormal = stateName === 'normal'; if (stateModel) { const stateShow = stateModel.get('show'); const isLabelIgnored = isNormal ? labelIgnoreNormal : retrieve2(label.states[stateName] && label.states[stateName].ignore, labelIgnoreNormal); if (isLabelIgnored // Not show when label is not shown in this state. || !retrieve2(stateShow, showNormal) // Use normal state by default if not set. ) { const stateObj = isNormal ? labelLine : (labelLine && labelLine.states[stateName]); if (stateObj) { stateObj.ignore = true; } continue; } // Create labelLine if not exists if (!labelLine) { labelLine = new Polyline(); targetEl.setTextGuideLine(labelLine); // Reset state of normal because it's new created. // NOTE: NORMAL should always been the first! if (!isNormal && (labelIgnoreNormal || !showNormal)) { setLabelLineState(labelLine, true, 'normal', statesModels.normal); } // Use same state proxy. if (targetEl.stateProxy) { labelLine.stateProxy = targetEl.stateProxy; } } setLabelLineState(labelLine, false, stateName, stateModel); } } if (labelLine) { defaults(labelLine.style, defaultStyle); // Not fill. labelLine.style.fill = null; const showAbove = normalModel.get('showAbove'); const labelLineConfig = (targetEl.textGuideLineConfig = targetEl.textGuideLineConfig || {}); labelLineConfig.showAbove = showAbove || false; // Custom the buildPath. labelLine.buildPath = buildLabelLinePath; } } export function getLabelLineStatesModels( itemModel: Model & Partial>>, labelLineName?: LabelName ): Record { labelLineName = (labelLineName || 'labelLine') as LabelName; const statesModels = { normal: itemModel.getModel(labelLineName) as LabelLineModel } as Record; for (let i = 0; i < SPECIAL_STATES.length; i++) { const stateName = SPECIAL_STATES[i]; statesModels[stateName] = itemModel.getModel([stateName, labelLineName]); } return statesModels; }