UNPKG

zrender

Version:

A lightweight graphic library providing 2d draw for Apache ECharts

660 lines (590 loc) 21.3 kB
// TODO // 1. shadow // 2. Image: sx, sy, sw, sh import { adjustTextY, getIdURL, getMatrixStr, getPathPrecision, getShadowKey, getSRTTransformString, hasShadow, isAroundZero, isGradient, isImagePattern, isLinearGradient, isPattern, isRadialGradient, normalizeColor, round4, TEXT_ALIGN_TO_ANCHOR } from './helper'; import Path, { PathStyleProps } from '../graphic/Path'; import ZRImage, { ImageStyleProps } from '../graphic/Image'; import { getLineHeight } from '../contain/text'; import TSpan, { TSpanStyleProps } from '../graphic/TSpan'; import SVGPathRebuilder from './SVGPathRebuilder'; import mapStyleToAttrs from './mapStyleToAttrs'; import { SVGVNodeAttrs, createVNode, SVGVNode, vNodeToString, BrushScope, META_DATA_PREFIX } from './core'; import { MatrixArray } from '../core/matrix'; import Displayable from '../graphic/Displayable'; import { assert, clone, isFunction, isString, logError, map, retrieve2 } from '../core/util'; import Polyline from '../graphic/shape/Polyline'; import Polygon from '../graphic/shape/Polygon'; import { GradientObject } from '../graphic/Gradient'; import { ImagePatternObject, SVGPatternObject } from '../graphic/Pattern'; import { createOrUpdateImage } from '../graphic/helper/image'; import { ImageLike } from '../core/types'; import { createCSSAnimation } from './cssAnimation'; import { hasSeparateFont, parseFontSize } from '../graphic/Text'; import { DEFAULT_FONT, DEFAULT_FONT_FAMILY } from '../core/platform'; import { createCSSEmphasis } from './cssEmphasis'; import { getElementSSRData } from '../zrender'; const round = Math.round; function isImageLike(val: any): val is HTMLImageElement { return val && isString(val.src); } function isCanvasLike(val: any): val is HTMLCanvasElement { return val && isFunction(val.toDataURL); } type AllStyleOption = PathStyleProps | TSpanStyleProps | ImageStyleProps; function setStyleAttrs(attrs: SVGVNodeAttrs, style: AllStyleOption, el: Path | TSpan | ZRImage, scope: BrushScope) { mapStyleToAttrs((key, val) => { const isFillStroke = key === 'fill' || key === 'stroke'; if (isFillStroke && isGradient(val)) { setGradient(style, attrs, key, scope); } else if (isFillStroke && isPattern(val)) { setPattern(el, attrs, key, scope); } else if (isFillStroke && val === 'none') { // When is none, it cannot be interacted when ssr attrs[key] = 'transparent'; } else { attrs[key] = val; } }, style, el, false); setShadow(el, attrs, scope); } function setMetaData(attrs: SVGVNodeAttrs, el: Path | TSpan | ZRImage) { const metaData = getElementSSRData(el); if (metaData) { metaData.each((val, key) => { val != null && (attrs[(META_DATA_PREFIX + key).toLowerCase()] = val + ''); }); if (el.isSilent()) { attrs[META_DATA_PREFIX + 'silent'] = 'true'; } } } function noRotateScale(m: MatrixArray) { return isAroundZero(m[0] - 1) && isAroundZero(m[1]) && isAroundZero(m[2]) && isAroundZero(m[3] - 1); } function noTranslate(m: MatrixArray) { return isAroundZero(m[4]) && isAroundZero(m[5]); } function setTransform(attrs: SVGVNodeAttrs, m: MatrixArray, compress?: boolean) { if (m && !(noTranslate(m) && noRotateScale(m))) { const mul = compress ? 10 : 1e4; // Use translate possible to reduce the size a bit. attrs.transform = noRotateScale(m) ? `translate(${round(m[4] * mul) / mul} ${round(m[5] * mul) / mul})` : getMatrixStr(m); } } type ShapeMapDesc = (string | [string, string])[]; type ConvertShapeToAttr = (shape: any, attrs: SVGVNodeAttrs, mul?: number) => void; type ShapeValidator = (shape: any) => boolean; function convertPolyShape(shape: Polygon['shape'], attrs: SVGVNodeAttrs, mul: number) { const points = shape.points; const strArr = []; for (let i = 0; i < points.length; i++) { strArr.push(round(points[i][0] * mul) / mul); strArr.push(round(points[i][1] * mul) / mul); } attrs.points = strArr.join(' '); } function validatePolyShape(shape: Polyline['shape']) { return !shape.smooth; } function createAttrsConvert(desc: ShapeMapDesc): ConvertShapeToAttr { const normalizedDesc: [string, string][] = map(desc, (item) => (typeof item === 'string' ? [item, item] : item) ); return function (shape, attrs, mul) { for (let i = 0; i < normalizedDesc.length; i++) { const item = normalizedDesc[i]; const val = shape[item[0]]; if (val != null) { attrs[item[1]] = round(val * mul) / mul; } } }; } const builtinShapesDef: Record<string, [ConvertShapeToAttr, ShapeValidator?]> = { circle: [createAttrsConvert(['cx', 'cy', 'r'])], polyline: [convertPolyShape, validatePolyShape], polygon: [convertPolyShape, validatePolyShape] // Ignore line because it will be larger. }; interface PathWithSVGBuildPath extends Path { __svgPathVersion: number __svgPathBuilder: SVGPathRebuilder __svgPathStrokePercent: number } function hasShapeAnimation(el: Displayable) { const animators = el.animators; for (let i = 0; i < animators.length; i++) { if (animators[i].targetName === 'shape') { return true; } } return false; } export function brushSVGPath(el: Path, scope: BrushScope) { const style = el.style; const shape = el.shape; const builtinShpDef = builtinShapesDef[el.type]; const attrs: SVGVNodeAttrs = {}; const needsAnimate = scope.animation; let svgElType = 'path'; const strokePercent = el.style.strokePercent; const precision = (scope.compress && getPathPrecision(el)) || 4; // Using SVG builtin shapes if possible if (builtinShpDef // Force to use path if it will update later. // To avoid some animation(like morph) fail && !scope.willUpdate && !(builtinShpDef[1] && !builtinShpDef[1](shape)) // use `path` to simplify the animate element creation logic. && !(needsAnimate && hasShapeAnimation(el)) && !(strokePercent < 1) ) { svgElType = el.type; const mul = Math.pow(10, precision); builtinShpDef[0](shape, attrs, mul); } else { const needBuildPath = !el.path || el.shapeChanged(); if (!el.path) { el.createPathProxy(); } const path = el.path; if (needBuildPath) { path.beginPath(); el.buildPath(path, el.shape); el.pathUpdated(); } const pathVersion = path.getVersion(); const elExt = el as PathWithSVGBuildPath; let svgPathBuilder = elExt.__svgPathBuilder; if (elExt.__svgPathVersion !== pathVersion || !svgPathBuilder || strokePercent !== elExt.__svgPathStrokePercent ) { if (!svgPathBuilder) { svgPathBuilder = elExt.__svgPathBuilder = new SVGPathRebuilder(); } svgPathBuilder.reset(precision); path.rebuildPath(svgPathBuilder, strokePercent); svgPathBuilder.generateStr(); elExt.__svgPathVersion = pathVersion; elExt.__svgPathStrokePercent = strokePercent; } attrs.d = svgPathBuilder.getStr(); } setTransform(attrs, el.transform); setStyleAttrs(attrs, style, el, scope); setMetaData(attrs, el); scope.animation && createCSSAnimation(el, attrs, scope); scope.emphasis && createCSSEmphasis(el, attrs, scope); return createVNode(svgElType, el.id + '', attrs); } export function brushSVGImage(el: ZRImage, scope: BrushScope) { const style = el.style; let image = style.image; if (image && !isString(image)) { if (isImageLike(image)) { image = image.src; } // heatmap layer in geo may be a canvas else if (isCanvasLike(image)) { image = image.toDataURL(); } } if (!image) { return; } const x = style.x || 0; const y = style.y || 0; const dw = style.width; const dh = style.height; const attrs: SVGVNodeAttrs = { href: image as string, width: dw, height: dh }; if (x) { attrs.x = x; } if (y) { attrs.y = y; } setTransform(attrs, el.transform); setStyleAttrs(attrs, style, el, scope); setMetaData(attrs, el); scope.animation && createCSSAnimation(el, attrs, scope); return createVNode('image', el.id + '', attrs); }; export function brushSVGTSpan(el: TSpan, scope: BrushScope) { const style = el.style; let text = style.text; // Convert to string text != null && (text += ''); if (!text || isNaN(style.x) || isNaN(style.y)) { return; } // style.font has been normalized by `normalizeTextStyle`. const font = style.font || DEFAULT_FONT; // Consider different font display differently in vertical align, we always // set verticalAlign as 'middle', and use 'y' to locate text vertically. const x = style.x || 0; const y = adjustTextY(style.y || 0, getLineHeight(font), style.textBaseline); const textAlign = TEXT_ALIGN_TO_ANCHOR[style.textAlign as keyof typeof TEXT_ALIGN_TO_ANCHOR] || style.textAlign; const attrs: SVGVNodeAttrs = { 'dominant-baseline': 'central', 'text-anchor': textAlign }; if (hasSeparateFont(style)) { // Set separate font attributes if possible. Or some platform like PowerPoint may not support it. let separatedFontStr = ''; const fontStyle = style.fontStyle; const fontSize = parseFontSize(style.fontSize); if (!parseFloat(fontSize)) { // is 0px return; } const fontFamily = style.fontFamily || DEFAULT_FONT_FAMILY; const fontWeight = style.fontWeight; separatedFontStr += `font-size:${fontSize};font-family:${fontFamily};`; // TODO reduce the attribute to set. But should it inherit from the container element? if (fontStyle && fontStyle !== 'normal') { separatedFontStr += `font-style:${fontStyle};`; } if (fontWeight && fontWeight !== 'normal') { separatedFontStr += `font-weight:${fontWeight};`; } attrs.style = separatedFontStr; } else { // Use set font manually attrs.style = `font: ${font}`; } if (text.match(/\s/)) { // only enabled when have space in text. attrs['xml:space'] = 'preserve'; } if (x) { attrs.x = x; } if (y) { attrs.y = y; } setTransform(attrs, el.transform); setStyleAttrs(attrs, style, el, scope); setMetaData(attrs, el); scope.animation && createCSSAnimation(el, attrs, scope); return createVNode('text', el.id + '', attrs, undefined, text); } export function brush(el: Displayable, scope: BrushScope): SVGVNode { if (el instanceof Path) { return brushSVGPath(el, scope); } else if (el instanceof ZRImage) { return brushSVGImage(el, scope); } else if (el instanceof TSpan) { return brushSVGTSpan(el, scope); } } function setShadow( el: Displayable, attrs: SVGVNodeAttrs, scope: BrushScope ) { const style = el.style; if (hasShadow(style)) { const shadowKey = getShadowKey(el); const shadowCache = scope.shadowCache; let shadowId = shadowCache[shadowKey]; if (!shadowId) { const globalScale = el.getGlobalScale(); const scaleX = globalScale[0]; const scaleY = globalScale[1]; if (!scaleX || !scaleY) { return; } const offsetX = style.shadowOffsetX || 0; const offsetY = style.shadowOffsetY || 0; const blur = style.shadowBlur; const {opacity, color} = normalizeColor(style.shadowColor); const stdDx = blur / 2 / scaleX; const stdDy = blur / 2 / scaleY; const stdDeviation = stdDx + ' ' + stdDy; // Use a simple prefix to reduce the size shadowId = scope.zrId + '-s' + scope.shadowIdx++; scope.defs[shadowId] = createVNode( 'filter', shadowId, { 'id': shadowId, 'x': '-100%', 'y': '-100%', 'width': '300%', 'height': '300%' }, [ createVNode('feDropShadow', '', { 'dx': offsetX / scaleX, 'dy': offsetY / scaleY, 'stdDeviation': stdDeviation, 'flood-color': color, 'flood-opacity': opacity }) ] ); shadowCache[shadowKey] = shadowId; } attrs.filter = getIdURL(shadowId); } } export function setGradient( style: PathStyleProps, attrs: SVGVNodeAttrs, target: 'fill' | 'stroke', scope: BrushScope ) { const val = style[target] as GradientObject; let gradientTag; let gradientAttrs: SVGVNodeAttrs = { 'gradientUnits': val.global ? 'userSpaceOnUse' // x1, x2, y1, y2 in range of 0 to canvas width or height : 'objectBoundingBox' // x1, x2, y1, y2 in range of 0 to 1] }; if (isLinearGradient(val)) { gradientTag = 'linearGradient'; gradientAttrs.x1 = val.x; gradientAttrs.y1 = val.y; gradientAttrs.x2 = val.x2; gradientAttrs.y2 = val.y2; } else if (isRadialGradient(val)) { gradientTag = 'radialGradient'; gradientAttrs.cx = retrieve2(val.x, 0.5); gradientAttrs.cy = retrieve2(val.y, 0.5); gradientAttrs.r = retrieve2(val.r, 0.5); } else { if (process.env.NODE_ENV !== 'production') { logError('Illegal gradient type.'); } return; } const colors = val.colorStops; const colorStops = []; for (let i = 0, len = colors.length; i < len; ++i) { const offset = round4(colors[i].offset) * 100 + '%'; const stopColor = colors[i].color; // Fix Safari bug that stop-color not recognizing alpha #9014 const {color, opacity} = normalizeColor(stopColor); const stopsAttrs: SVGVNodeAttrs = { 'offset': offset }; // stop-color cannot be color, since: // The opacity value used for the gradient calculation is the // *product* of the value of stop-opacity and the opacity of the // value of stop-color. // See https://www.w3.org/TR/SVG2/pservers.html#StopOpacityProperty stopsAttrs['stop-color'] = color; if (opacity < 1) { stopsAttrs['stop-opacity'] = opacity; } colorStops.push( createVNode('stop', i + '', stopsAttrs) ); } // Use the whole html as cache key. const gradientVNode = createVNode(gradientTag, '', gradientAttrs, colorStops); const gradientKey = vNodeToString(gradientVNode); const gradientCache = scope.gradientCache; let gradientId = gradientCache[gradientKey]; if (!gradientId) { gradientId = scope.zrId + '-g' + scope.gradientIdx++; gradientCache[gradientKey] = gradientId; gradientAttrs.id = gradientId; scope.defs[gradientId] = createVNode( gradientTag, gradientId, gradientAttrs, colorStops ); } attrs[target] = getIdURL(gradientId); } export function setPattern( el: Displayable, attrs: SVGVNodeAttrs, target: 'fill' | 'stroke', scope: BrushScope ) { const val = el.style[target] as ImagePatternObject | SVGPatternObject; const boundingRect = el.getBoundingRect(); const patternAttrs: SVGVNodeAttrs = {}; const repeat = (val as ImagePatternObject).repeat; const noRepeat = repeat === 'no-repeat'; const repeatX = repeat === 'repeat-x'; const repeatY = repeat === 'repeat-y'; let child: SVGVNode; if (isImagePattern(val)) { let imageWidth = val.imageWidth; let imageHeight = val.imageHeight; let imageSrc; const patternImage = val.image; if (isString(patternImage)) { imageSrc = patternImage; } else if (isImageLike(patternImage)) { imageSrc = patternImage.src; } else if (isCanvasLike(patternImage)) { imageSrc = patternImage.toDataURL(); } if (typeof Image === 'undefined') { const errMsg = 'Image width/height must been given explictly in svg-ssr renderer.'; assert(imageWidth, errMsg); assert(imageHeight, errMsg); } else if (imageWidth == null || imageHeight == null) { // TODO const setSizeToVNode = (vNode: SVGVNode, img: ImageLike) => { if (vNode) { const svgEl = vNode.elm as SVGElement; let width = imageWidth || img.width; let height = imageHeight || img.height; if (vNode.tag === 'pattern') { if (repeatX) { height = 1; width /= boundingRect.width; } else if (repeatY) { width = 1; height /= boundingRect.height; } } vNode.attrs.width = width; vNode.attrs.height = height; if (svgEl) { svgEl.setAttribute('width', width as any); svgEl.setAttribute('height', height as any); } } }; const createdImage = createOrUpdateImage( imageSrc, null, el, (img) => { noRepeat || setSizeToVNode(patternVNode, img); setSizeToVNode(child, img); } ); if (createdImage && createdImage.width && createdImage.height) { // Loaded before imageWidth = imageWidth || createdImage.width; imageHeight = imageHeight || createdImage.height; } } child = createVNode( 'image', 'img', { href: imageSrc, width: imageWidth, height: imageHeight } ); patternAttrs.width = imageWidth; patternAttrs.height = imageHeight; } else if (val.svgElement) { // Only string supported in SSR. // TODO it's not so good to use textContent as innerHTML child = clone(val.svgElement); patternAttrs.width = val.svgWidth; patternAttrs.height = val.svgHeight; } if (!child) { return; } let patternWidth; let patternHeight; if (noRepeat) { patternWidth = patternHeight = 1; } else if (repeatX) { patternHeight = 1; patternWidth = (patternAttrs.width as number) / boundingRect.width; } else if (repeatY) { patternWidth = 1; patternHeight = (patternAttrs.height as number) / boundingRect.height; } else { patternAttrs.patternUnits = 'userSpaceOnUse'; } if (patternWidth != null && !isNaN(patternWidth)) { patternAttrs.width = patternWidth; } if (patternHeight != null && !isNaN(patternHeight)) { patternAttrs.height = patternHeight; } const patternTransform = getSRTTransformString(val); patternTransform && (patternAttrs.patternTransform = patternTransform); // Use the whole html as cache key. let patternVNode = createVNode( 'pattern', '', patternAttrs, [child] ); const patternKey = vNodeToString(patternVNode); const patternCache = scope.patternCache; let patternId = patternCache[patternKey]; if (!patternId) { patternId = scope.zrId + '-p' + scope.patternIdx++; patternCache[patternKey] = patternId; patternAttrs.id = patternId; patternVNode = scope.defs[patternId] = createVNode( 'pattern', patternId, patternAttrs, [child] ); } attrs[target] = getIdURL(patternId); } export function setClipPath( clipPath: Path, attrs: SVGVNodeAttrs, scope: BrushScope ) { const {clipPathCache, defs} = scope; let clipPathId = clipPathCache[clipPath.id]; if (!clipPathId) { clipPathId = scope.zrId + '-c' + scope.clipPathIdx++; const clipPathAttrs: SVGVNodeAttrs = { id: clipPathId }; clipPathCache[clipPath.id] = clipPathId; defs[clipPathId] = createVNode( 'clipPath', clipPathId, clipPathAttrs, [brushSVGPath(clipPath, scope)] ); } attrs['clip-path'] = getIdURL(clipPathId); }