// 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 } 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';
|
|
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 {
|
attrs[key] = val;
|
}
|
}, style, el, false);
|
|
setShadow(el, attrs, scope);
|
}
|
|
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 buitinShapesDef: 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 = buitinShapesDef[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 {
|
if (!el.path) {
|
el.createPathProxy();
|
}
|
const path = el.path;
|
|
if (el.shapeChanged()) {
|
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);
|
|
scope.animation && createCSSAnimation(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);
|
|
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 vertial align, we always
|
// set vertialAlign 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);
|
|
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);
|
}
|
}
|
|
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);
|
}
|
|
function setPattern(
|
el: Displayable,
|
attrs: SVGVNodeAttrs,
|
target: 'fill' | 'stroke',
|
scope: BrushScope
|
) {
|
const val = el.style[target] as ImagePatternObject | SVGPatternObject;
|
const patternAttrs: SVGVNodeAttrs = {
|
'patternUnits': 'userSpaceOnUse'
|
};
|
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;
|
const width = (vNode.attrs.width = imageWidth || img.width);
|
const height = (vNode.attrs.height = imageHeight || img.height);
|
if (svgEl) {
|
svgEl.setAttribute('width', width as any);
|
svgEl.setAttribute('height', height as any);
|
}
|
}
|
};
|
const createdImage = createOrUpdateImage(
|
imageSrc, null, el, (img) => {
|
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;
|
}
|
|
patternAttrs.patternTransform = getSRTTransformString(val);
|
|
// 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);
|
}
|