前端初始化

This commit is contained in:
“hsc”
2026-08-21 15:36:24 +08:00
commit e4d8ecde13
14411 changed files with 2275932 additions and 0 deletions
+409
View File
@@ -0,0 +1,409 @@
/**
* SVG Painter
*/
import {
brush,
setClipPath,
setGradient,
setPattern
} from './graphic';
import Displayable from '../graphic/Displayable';
import Storage from '../Storage';
import { PainterBase } from '../PainterBase';
import {
createElement,
createVNode,
vNodeToString,
SVGVNodeAttrs,
SVGVNode,
getCssString,
BrushScope,
createBrushScope,
createSVGVNode
} from './core';
import { normalizeColor, encodeBase64, isGradient, isPattern } from './helper';
import { extend, keys, logError, map, noop, retrieve2 } from '../core/util';
import Path from '../graphic/Path';
import patch, { updateAttrs } from './patch';
import { getSize } from '../canvas/helper';
import { GradientObject } from '../graphic/Gradient';
import { PatternObject } from '../graphic/Pattern';
let svgId = 0;
interface SVGPainterOption {
width?: number
height?: number
ssr?: boolean
}
type SVGPainterBackgroundColor = string | GradientObject | PatternObject;
class SVGPainter implements PainterBase {
type = 'svg'
storage: Storage
root: HTMLElement
private _svgDom: SVGElement
private _viewport: HTMLElement
private _opts: SVGPainterOption
private _oldVNode: SVGVNode
private _bgVNode: SVGVNode
private _mainVNode: SVGVNode
private _width: number
private _height: number
private _backgroundColor: SVGPainterBackgroundColor
private _id: string
constructor(root: HTMLElement, storage: Storage, opts: SVGPainterOption) {
this.storage = storage;
this._opts = opts = extend({}, opts);
this.root = root;
// A unique id for generating svg ids.
this._id = 'zr' + svgId++;
this._oldVNode = createSVGVNode(opts.width, opts.height);
if (root && !opts.ssr) {
const viewport = this._viewport = document.createElement('div');
viewport.style.cssText = 'position:relative;overflow:hidden';
const svgDom = this._svgDom = this._oldVNode.elm = createElement('svg');
updateAttrs(null, this._oldVNode);
viewport.appendChild(svgDom);
root.appendChild(viewport);
}
this.resize(opts.width, opts.height);
}
getType() {
return this.type;
}
getViewportRoot() {
return this._viewport;
}
getViewportRootOffset() {
const viewportRoot = this.getViewportRoot();
if (viewportRoot) {
return {
offsetLeft: viewportRoot.offsetLeft || 0,
offsetTop: viewportRoot.offsetTop || 0
};
}
}
getSvgDom() {
return this._svgDom;
}
refresh() {
if (this.root) {
const vnode = this.renderToVNode({
willUpdate: true
});
// Disable user selection.
vnode.attrs.style = 'position:absolute;left:0;top:0;user-select:none';
patch(this._oldVNode, vnode);
this._oldVNode = vnode;
}
}
renderOneToVNode(el: Displayable) {
return brush(el, createBrushScope(this._id));
}
renderToVNode(opts?: {
animation?: boolean,
willUpdate?: boolean,
compress?: boolean,
useViewBox?: boolean,
emphasis?: boolean
}) {
opts = opts || {};
const list = this.storage.getDisplayList(true);
const width = this._width;
const height = this._height;
const scope = createBrushScope(this._id);
scope.animation = opts.animation;
scope.willUpdate = opts.willUpdate;
scope.compress = opts.compress;
scope.emphasis = opts.emphasis;
scope.ssr = this._opts.ssr;
const children: SVGVNode[] = [];
const bgVNode = this._bgVNode = createBackgroundVNode(width, height, this._backgroundColor, scope);
bgVNode && children.push(bgVNode);
// Ignore the root g if wan't the output to be more tight.
const mainVNode = !opts.compress
? (this._mainVNode = createVNode('g', 'main', {}, [])) : null;
this._paintList(list, scope, mainVNode ? mainVNode.children : children);
mainVNode && children.push(mainVNode);
const defs = map(keys(scope.defs), (id) => scope.defs[id]);
if (defs.length) {
children.push(createVNode('defs', 'defs', {}, defs));
}
if (opts.animation) {
const animationCssStr = getCssString(scope.cssNodes, scope.cssAnims, { newline: true });
if (animationCssStr) {
const styleNode = createVNode('style', 'stl', {}, [], animationCssStr);
children.push(styleNode);
}
}
return createSVGVNode(width, height, children, opts.useViewBox);
}
renderToString(opts?: {
/**
* If add css animation.
* @default true
*/
cssAnimation?: boolean,
/**
* If add css emphasis.
* @default true
*/
cssEmphasis?: boolean,
/**
* If use viewBox
* @default true
*/
useViewBox?: boolean
}) {
opts = opts || {};
return vNodeToString(this.renderToVNode({
animation: retrieve2(opts.cssAnimation, true),
emphasis: retrieve2(opts.cssEmphasis, true),
willUpdate: false,
compress: true,
useViewBox: retrieve2(opts.useViewBox, true)
}), { newline: true });
}
setBackgroundColor(backgroundColor: SVGPainterBackgroundColor) {
this._backgroundColor = backgroundColor;
}
getSvgRoot() {
return this._mainVNode && this._mainVNode.elm as SVGElement;
}
_paintList(list: Displayable[], scope: BrushScope, out?: SVGVNode[]) {
const listLen = list.length;
const clipPathsGroupsStack: SVGVNode[] = [];
let clipPathsGroupsStackDepth = 0;
let currentClipPathGroup;
let prevClipPaths: Path[];
let clipGroupNodeIdx = 0;
for (let i = 0; i < listLen; i++) {
const displayable = list[i];
if (!displayable.invisible) {
const clipPaths = displayable.__clipPaths;
const len = clipPaths && clipPaths.length || 0;
const prevLen = prevClipPaths && prevClipPaths.length || 0;
let lca;
// Find the lowest common ancestor
for (lca = Math.max(len - 1, prevLen - 1); lca >= 0; lca--) {
if (clipPaths && prevClipPaths
&& clipPaths[lca] === prevClipPaths[lca]
) {
break;
}
}
// pop the stack
for (let i = prevLen - 1; i > lca; i--) {
clipPathsGroupsStackDepth--;
// svgEls.push(closeGroup);
currentClipPathGroup = clipPathsGroupsStack[clipPathsGroupsStackDepth - 1];
}
// Pop clip path group for clipPaths not match the previous.
for (let i = lca + 1; i < len; i++) {
const groupAttrs: SVGVNodeAttrs = {};
setClipPath(
clipPaths[i],
groupAttrs,
scope
);
const g = createVNode(
'g',
'clip-g-' + clipGroupNodeIdx++,
groupAttrs,
[]
);
(currentClipPathGroup ? currentClipPathGroup.children : out).push(g);
clipPathsGroupsStack[clipPathsGroupsStackDepth++] = g;
currentClipPathGroup = g;
}
prevClipPaths = clipPaths;
const ret = brush(displayable, scope);
if (ret) {
(currentClipPathGroup ? currentClipPathGroup.children : out).push(ret);
}
}
}
}
resize(width: number, height: number) {
// Save input w/h
const opts = this._opts;
const root = this.root;
const viewport = this._viewport;
width != null && (opts.width = width);
height != null && (opts.height = height);
if (root && viewport) {
// FIXME Why ?
viewport.style.display = 'none';
width = getSize(root, 0, opts);
height = getSize(root, 1, opts);
viewport.style.display = '';
}
if (this._width !== width || this._height !== height) {
this._width = width;
this._height = height;
if (viewport) {
const viewportStyle = viewport.style;
viewportStyle.width = width + 'px';
viewportStyle.height = height + 'px';
}
if (!isPattern(this._backgroundColor)) {
const svgDom = this._svgDom;
if (svgDom) {
// Set width by 'svgRoot.width = width' is invalid
svgDom.setAttribute('width', width as any);
svgDom.setAttribute('height', height as any);
}
const bgEl = this._bgVNode && this._bgVNode.elm as SVGElement;
if (bgEl) {
bgEl.setAttribute('width', width as any);
bgEl.setAttribute('height', height as any);
}
}
else {
// pattern backgroundColor requires a full refresh
this.refresh();
}
}
}
/**
* 获取绘图区域宽度
*/
getWidth() {
return this._width;
}
/**
* 获取绘图区域高度
*/
getHeight() {
return this._height;
}
dispose() {
if (this.root) {
this.root.innerHTML = '';
}
this._svgDom =
this._viewport =
this.storage =
this._oldVNode =
this._bgVNode =
this._mainVNode = null;
}
clear() {
if (this._svgDom) {
this._svgDom.innerHTML = null;
}
this._oldVNode = null;
}
toDataURL(base64?: boolean) {
let str = this.renderToString();
const prefix = 'data:image/svg+xml;';
if (base64) {
str = encodeBase64(str);
return str && prefix + 'base64,' + str;
}
return prefix + 'charset=UTF-8,' + encodeURIComponent(str);
}
configLayer = createMethodNotSupport('configLayer') as PainterBase['configLayer'];
}
// Not supported methods
function createMethodNotSupport(method: string): any {
return function () {
if (process.env.NODE_ENV !== 'production') {
logError('In SVG mode painter not support method "' + method + '"');
}
};
}
function createBackgroundVNode(
width: number,
height: number,
backgroundColor: SVGPainterBackgroundColor,
scope: BrushScope
) {
let bgVNode;
if (backgroundColor && backgroundColor !== 'none') {
bgVNode = createVNode(
'rect',
'bg',
{
width,
height,
x: '0',
y: '0'
}
);
if (isGradient(backgroundColor)) {
setGradient({ fill: backgroundColor as any }, bgVNode.attrs, 'fill', scope);
}
else if (isPattern(backgroundColor)) {
setPattern({
style: {
fill: backgroundColor
},
dirty: noop,
getBoundingRect: () => ({ width, height })
} as any, bgVNode.attrs, 'fill', scope);
}
else {
const { color, opacity } = normalizeColor(backgroundColor);
bgVNode.attrs.fill = color;
opacity < 1 && (bgVNode.attrs['fill-opacity'] = opacity);
}
}
return bgVNode;
}
export default SVGPainter;
+151
View File
@@ -0,0 +1,151 @@
import { PathRebuilder } from '../core/PathProxy';
import { isAroundZero } from './helper';
const mathSin = Math.sin;
const mathCos = Math.cos;
const PI = Math.PI;
const PI2 = Math.PI * 2;
const degree = 180 / PI;
export default class SVGPathRebuilder implements PathRebuilder {
private _d: (string | number)[]
private _str: string
private _invalid: boolean
// If is start of subpath
private _start: boolean
private _p: number
reset(precision?: number) {
this._start = true;
this._d = [];
this._str = '';
this._p = Math.pow(10, precision || 4);
}
moveTo(x: number, y: number) {
this._add('M', x, y);
}
lineTo(x: number, y: number) {
this._add('L', x, y);
}
bezierCurveTo(x: number, y: number, x2: number, y2: number, x3: number, y3: number) {
this._add('C', x, y, x2, y2, x3, y3);
}
quadraticCurveTo(x: number, y: number, x2: number, y2: number) {
this._add('Q', x, y, x2, y2);
}
arc(cx: number, cy: number, r: number, startAngle: number, endAngle: number, anticlockwise: boolean) {
this.ellipse(cx, cy, r, r, 0, startAngle, endAngle, anticlockwise);
}
ellipse(
cx: number, cy: number,
rx: number, ry: number,
psi: number,
startAngle: number,
endAngle: number,
anticlockwise: boolean
) {
let dTheta = endAngle - startAngle;
const clockwise = !anticlockwise;
const dThetaPositive = Math.abs(dTheta);
const isCircle = isAroundZero(dThetaPositive - PI2)
|| (clockwise ? dTheta >= PI2 : -dTheta >= PI2);
// Mapping to 0~2PI
const unifiedTheta = dTheta > 0 ? dTheta % PI2 : (dTheta % PI2 + PI2);
let large = false;
if (isCircle) {
large = true;
}
else if (isAroundZero(dThetaPositive)) {
large = false;
}
else {
large = (unifiedTheta >= PI) === !!clockwise;
}
const x0 = cx + rx * mathCos(startAngle);
const y0 = cy + ry * mathSin(startAngle);
if (this._start) {
// Move to (x0, y0) only when CMD.A comes at the
// first position of a shape.
// For instance, when drawing a ring, CMD.A comes
// after CMD.M, so it's unnecessary to move to
// (x0, y0).
this._add('M', x0, y0);
}
const xRot = Math.round(psi * degree);
// It will not draw if start point and end point are exactly the same
// We need to add two arcs
if (isCircle) {
const p = 1 / this._p;
const dTheta = (clockwise ? 1 : -1) * (PI2 - p);
this._add(
'A', rx, ry, xRot, 1, +clockwise,
cx + rx * mathCos(startAngle + dTheta),
cy + ry * mathSin(startAngle + dTheta)
);
// TODO.
// Usually we can simply divide the circle into two halfs arcs.
// But it will cause slightly diff with previous screenshot.
// We can't tell it but visual regression test can. To avoid too much breaks.
// We keep the logic on the browser as before.
// But in SSR mode wich has lower precision. We close the circle by adding another arc.
if (p > 1e-2) {
this._add('A', rx, ry, xRot, 0, +clockwise, x0, y0);
}
}
else {
const x = cx + rx * mathCos(endAngle);
const y = cy + ry * mathSin(endAngle);
// FIXME Ellipse
this._add('A', rx, ry, xRot, +large, +clockwise, x, y);
}
}
rect(x: number, y: number, w: number, h: number) {
this._add('M', x, y);
// Use relative coordinates to reduce the size.
this._add('l', w, 0);
this._add('l', 0, h);
this._add('l', -w, 0);
// this._add('L', x, y);
this._add('Z');
}
closePath() {
// Not use Z as first command
if (this._d.length > 0) {
this._add('Z');
}
}
_add(cmd: string, a?: number, b?: number, c?: number, d?: number, e?: number, f?: number, g?: number, h?: number) {
const vals = [];
const p = this._p;
for (let i = 1; i < arguments.length; i++) {
const val = arguments[i];
if (isNaN(val)) {
this._invalid = true;
return;
}
vals.push(Math.round(val * p) / p);
}
this._d.push(cmd + vals.join(' '));
this._start = cmd === 'Z';
}
generateStr() {
this._str = this._invalid ? '' : this._d.join('');
this._d = [];
}
getStr() {
return this._str;
}
}
+208
View File
@@ -0,0 +1,208 @@
import { keys, map } from '../core/util';
import { encodeHTML } from '../core/dom';
export type CSSSelectorVNode = Record<string, string>
export type CSSAnimationVNode = Record<string, Record<string, string>>
export const SVGNS = 'http://www.w3.org/2000/svg';
export const XLINKNS = 'http://www.w3.org/1999/xlink';
export const XMLNS = 'http://www.w3.org/2000/xmlns/';
export const XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace';
export const META_DATA_PREFIX = 'ecmeta_';
export function createElement(name: string) {
return document.createElementNS(SVGNS, name);
}
export type SVGVNodeAttrs = Record<string, string | number | undefined | boolean>
export interface SVGVNode {
tag: string,
attrs: SVGVNodeAttrs,
children?: SVGVNode[],
text?: string
// For patching
elm?: Node
key: string
};
export function createVNode(
tag: string,
key: string,
attrs?: SVGVNodeAttrs,
children?: SVGVNode[],
text?: string
): SVGVNode {
return {
tag,
attrs: attrs || {},
children,
text,
key
};
}
function createElementOpen(name: string, attrs?: SVGVNodeAttrs) {
const attrsStr: string[] = [];
if (attrs) {
// eslint-disable-next-line
for (let key in attrs) {
const val = attrs[key];
let part = key;
// Same with the logic in patch.
if (val === false) {
continue;
}
else if (val !== true && val != null) {
part += `="${val}"`;
}
attrsStr.push(part);
}
}
return `<${name} ${attrsStr.join(' ')}>`;
}
function createElementClose(name: string) {
return `</${name}>`;
}
export function vNodeToString(el: SVGVNode, opts?: {
newline?: boolean
}) {
opts = opts || {};
const S = opts.newline ? '\n' : '';
function convertElToString(el: SVGVNode): string {
const {children, tag, attrs, text} = el;
return createElementOpen(tag, attrs)
+ (tag !== 'style' ? encodeHTML(text) : text || '')
+ (children ? `${S}${map(children, child => convertElToString(child)).join(S)}${S}` : '')
+ createElementClose(tag);
}
return convertElToString(el);
}
export function getCssString(
selectorNodes: Record<string, CSSSelectorVNode>,
animationNodes: Record<string, CSSAnimationVNode>,
opts?: {
newline?: boolean
}
) {
opts = opts || {};
const S = opts.newline ? '\n' : '';
const bracketBegin = ` {${S}`;
const bracketEnd = `${S}}`;
const selectors = map(keys(selectorNodes), className => {
return className + bracketBegin + map(keys(selectorNodes[className]), attrName => {
return `${attrName}:${selectorNodes[className][attrName]};`;
}).join(S) + bracketEnd;
}).join(S);
const animations = map(keys(animationNodes), (animationName) => {
return `@keyframes ${animationName}${bracketBegin}` + map(keys(animationNodes[animationName]), percent => {
return percent + bracketBegin + map(keys(animationNodes[animationName][percent]), attrName => {
let val = animationNodes[animationName][percent][attrName];
// postprocess
if (attrName === 'd') {
val = `path("${val}")`;
}
return `${attrName}:${val};`;
}).join(S) + bracketEnd;
}).join(S) + bracketEnd;
}).join(S);
if (!selectors && !animations) {
return '';
}
return ['<![CDATA[', selectors, animations, ']]>'].join(S);
}
export interface BrushScope {
zrId: string
shadowCache: Record<string, string>
gradientCache: Record<string, string>
patternCache: Record<string, string>
clipPathCache: Record<string, string>
defs: Record<string, SVGVNode>
cssNodes: Record<string, CSSSelectorVNode>
cssAnims: Record<string, Record<string, Record<string, string>>>
/**
* Cache for css style string, mapping from style string to class name.
*/
cssStyleCache: Record<string, string>
cssAnimIdx: number
shadowIdx: number
gradientIdx: number
patternIdx: number
clipPathIdx: number
// configs
/**
* If create animates nodes.
*/
animation?: boolean,
/**
* If create emphasis styles.
*/
emphasis?: boolean,
/**
* If will update. Some optimization for string generation can't be applied.
*/
willUpdate?: boolean
/**
* If compress the output string.
*/
compress?: boolean
ssr?: boolean
}
export function createBrushScope(zrId: string): BrushScope {
return {
zrId,
shadowCache: {},
patternCache: {},
gradientCache: {},
clipPathCache: {},
defs: {},
cssNodes: {},
cssAnims: {},
cssStyleCache: {},
cssAnimIdx: 0,
shadowIdx: 0,
gradientIdx: 0,
patternIdx: 0,
clipPathIdx: 0
};
}
export function createSVGVNode(
width: number | string,
height: number | string,
children?: SVGVNode[],
useViewBox?: boolean
) {
return createVNode(
'svg',
'root',
{
'width': width,
'height': height,
'xmlns': SVGNS,
'xmlns:xlink': XLINKNS,
'version': '1.1',
'baseProfile': 'full',
'viewBox': useViewBox ? `0 0 ${width} ${height}` : false
},
children
);
}
+366
View File
@@ -0,0 +1,366 @@
import Transformable, { copyTransform } from '../core/Transformable';
import Displayable from '../graphic/Displayable';
import { SVGVNodeAttrs, BrushScope, createBrushScope} from './core';
import Path from '../graphic/Path';
import SVGPathRebuilder from './SVGPathRebuilder';
import PathProxy from '../core/PathProxy';
import { getPathPrecision, getSRTTransformString } from './helper';
import { each, extend, filter, isNumber, isString, keys } from '../core/util';
import Animator from '../animation/Animator';
import CompoundPath from '../graphic/CompoundPath';
import { AnimationEasing } from '../animation/easing';
import { createCubicEasingFunc } from '../animation/cubicEasing';
import { getClassId } from './cssClassId';
export const EASING_MAP: Record<string, string> = {
// From https://easings.net/
cubicIn: '0.32,0,0.67,0',
cubicOut: '0.33,1,0.68,1',
cubicInOut: '0.65,0,0.35,1',
quadraticIn: '0.11,0,0.5,0',
quadraticOut: '0.5,1,0.89,1',
quadraticInOut: '0.45,0,0.55,1',
quarticIn: '0.5,0,0.75,0',
quarticOut: '0.25,1,0.5,1',
quarticInOut: '0.76,0,0.24,1',
quinticIn: '0.64,0,0.78,0',
quinticOut: '0.22,1,0.36,1',
quinticInOut: '0.83,0,0.17,1',
sinusoidalIn: '0.12,0,0.39,0',
sinusoidalOut: '0.61,1,0.88,1',
sinusoidalInOut: '0.37,0,0.63,1',
exponentialIn: '0.7,0,0.84,0',
exponentialOut: '0.16,1,0.3,1',
exponentialInOut: '0.87,0,0.13,1',
circularIn: '0.55,0,1,0.45',
circularOut: '0,0.55,0.45,1',
circularInOut: '0.85,0,0.15,1'
// TODO elastic, bounce
};
const transformOriginKey = 'transform-origin';
function buildPathString(el: Path, kfShape: Path['shape'], path: PathProxy) {
const shape = extend({}, el.shape);
extend(shape, kfShape);
el.buildPath(path, shape);
const svgPathBuilder = new SVGPathRebuilder();
svgPathBuilder.reset(getPathPrecision(el));
path.rebuildPath(svgPathBuilder, 1);
svgPathBuilder.generateStr();
// will add path("") when generated to css string in the final step.
return svgPathBuilder.getStr();
}
function setTransformOrigin(target: Record<string, string>, transform: Transformable) {
const {originX, originY} = transform;
if (originX || originY) {
target[transformOriginKey] = `${originX}px ${originY}px`;
}
}
export const ANIMATE_STYLE_MAP: Record<string, string> = {
fill: 'fill',
opacity: 'opacity',
lineWidth: 'stroke-width',
lineDashOffset: 'stroke-dashoffset'
// TODO shadow is not supported.
};
type CssKF = Record<string, any>;
function addAnimation(cssAnim: Record<string, CssKF>, scope: BrushScope) {
const animationName = scope.zrId + '-ani-' + scope.cssAnimIdx++;
scope.cssAnims[animationName] = cssAnim;
return animationName;
}
function createCompoundPathCSSAnimation(
el: CompoundPath,
attrs: SVGVNodeAttrs,
scope: BrushScope
) {
const paths = el.shape.paths;
const composedAnim: Record<string, CssKF> = {};
let cssAnimationCfg: string;
let cssAnimationName: string;
each(paths, path => {
const subScope = createBrushScope(scope.zrId);
subScope.animation = true;
createCSSAnimation(path, {}, subScope, true);
const cssAnims = subScope.cssAnims;
const cssNodes = subScope.cssNodes;
const animNames = keys(cssAnims);
const len = animNames.length;
if (!len) {
return;
}
cssAnimationName = animNames[len - 1];
// Only use last animation because they are conflicted.
const lastAnim = cssAnims[cssAnimationName];
// eslint-disable-next-line
for (let percent in lastAnim) {
const kf = lastAnim[percent];
composedAnim[percent] = composedAnim[percent] || { d: '' };
composedAnim[percent].d += kf.d || '';
}
// eslint-disable-next-line
for (let className in cssNodes) {
const val = cssNodes[className].animation;
if (val.indexOf(cssAnimationName) >= 0) {
// Only pick the animation configuration of last subpath.
cssAnimationCfg = val;
}
}
});
if (!cssAnimationCfg) {
return;
}
// Remove the attrs in the element because it will be set by animation.
// Reduce the size.
attrs.d = false;
const animationName = addAnimation(composedAnim, scope);
return cssAnimationCfg.replace(cssAnimationName, animationName);
}
function getEasingFunc(easing: AnimationEasing) {
return isString(easing)
? EASING_MAP[easing]
? `cubic-bezier(${EASING_MAP[easing]})`
: createCubicEasingFunc(easing) ? easing : ''
: '';
}
export function createCSSAnimation(
el: Displayable,
attrs: SVGVNodeAttrs,
scope: BrushScope,
onlyShape?: boolean
) {
const animators = el.animators;
const len = animators.length;
const cssAnimations: string[] = [];
if (el instanceof CompoundPath) {
const animationCfg = createCompoundPathCSSAnimation(el, attrs, scope);
if (animationCfg) {
cssAnimations.push(animationCfg);
}
else if (!len) {
return;
}
}
else if (!len) {
return;
}
// Group animators by it's configuration
const groupAnimators: Record<string, [string, Animator<any>[]]> = {};
for (let i = 0; i < len; i++) {
const animator = animators[i];
const cfgArr: (string | number)[] = [animator.getMaxTime() / 1000 + 's'];
const easing = getEasingFunc(animator.getClip().easing);
const delay = animator.getDelay();
if (easing) {
cfgArr.push(easing);
}
else {
cfgArr.push('linear');
}
if (delay) {
cfgArr.push(delay / 1000 + 's');
}
if (animator.getLoop()) {
cfgArr.push('infinite');
}
const cfg = cfgArr.join(' ');
// TODO fill mode
groupAnimators[cfg] = groupAnimators[cfg] || [cfg, [] as Animator<any>[]];
groupAnimators[cfg][1].push(animator);
}
function createSingleCSSAnimation(groupAnimator: [string, Animator<any>[]]) {
const animators = groupAnimator[1];
const len = animators.length;
const transformKfs: Record<string, CssKF> = {};
const shapeKfs: Record<string, CssKF> = {};
const finalKfs: Record<string, CssKF> = {};
const animationTimingFunctionAttrName = 'animation-timing-function';
function saveAnimatorTrackToCssKfs(
animator: Animator<any>,
cssKfs: Record<string, CssKF>,
toCssAttrName?: (propName: string) => string
) {
const tracks = animator.getTracks();
const maxTime = animator.getMaxTime();
for (let k = 0; k < tracks.length; k++) {
const track = tracks[k];
if (track.needsAnimate()) {
const kfs = track.keyframes;
let attrName = track.propName;
toCssAttrName && (attrName = toCssAttrName(attrName));
if (attrName) {
for (let i = 0; i < kfs.length; i++) {
const kf = kfs[i];
const percent = Math.round(kf.time / maxTime * 100) + '%';
const kfEasing = getEasingFunc(kf.easing);
const rawValue = kf.rawValue;
// TODO gradient
if (isString(rawValue) || isNumber(rawValue)) {
cssKfs[percent] = cssKfs[percent] || {};
cssKfs[percent][attrName] = kf.rawValue;
if (kfEasing) {
// TODO. If different property have different easings.
cssKfs[percent][animationTimingFunctionAttrName] = kfEasing;
}
}
}
}
}
}
}
// Find all transform animations.
// TODO origin, parent
for (let i = 0; i < len; i++) {
const animator = animators[i];
const targetProp = animator.targetName;
if (!targetProp) {
!onlyShape && saveAnimatorTrackToCssKfs(animator, transformKfs);
}
else if (targetProp === 'shape') {
saveAnimatorTrackToCssKfs(animator, shapeKfs);
}
}
// eslint-disable-next-line
for (let percent in transformKfs) {
const transform = {} as Transformable;
copyTransform(transform, el);
extend(transform, transformKfs[percent]);
const str = getSRTTransformString(transform);
const timingFunction = transformKfs[percent][animationTimingFunctionAttrName];
finalKfs[percent] = str ? {
transform: str
} : {};
// TODO set transform origin in element?
setTransformOrigin(finalKfs[percent], transform);
// Save timing function
if (timingFunction) {
finalKfs[percent][animationTimingFunctionAttrName] = timingFunction;
}
};
let path: PathProxy;
let canAnimateShape = true;
// eslint-disable-next-line
for (let percent in shapeKfs) {
finalKfs[percent] = finalKfs[percent] || {};
const isFirst = !path;
const timingFunction = shapeKfs[percent][animationTimingFunctionAttrName];
if (isFirst) {
path = new PathProxy();
}
let len = path.len();
path.reset();
finalKfs[percent].d = buildPathString(el as Path, shapeKfs[percent], path);
let newLen = path.len();
// Path data don't match.
if (!isFirst && len !== newLen) {
canAnimateShape = false;
break;
}
// Save timing function
if (timingFunction) {
finalKfs[percent][animationTimingFunctionAttrName] = timingFunction;
}
};
if (!canAnimateShape) {
// eslint-disable-next-line
for (let percent in finalKfs) {
delete finalKfs[percent].d;
}
}
if (!onlyShape) {
for (let i = 0; i < len; i++) {
const animator = animators[i];
const targetProp = animator.targetName;
if (targetProp === 'style') {
saveAnimatorTrackToCssKfs(
animator, finalKfs, (propName) => ANIMATE_STYLE_MAP[propName]
);
}
}
}
const percents = keys(finalKfs);
// Set transform origin in attribute to reduce the size.
let allTransformOriginSame = true;
let transformOrigin;
for (let i = 1; i < percents.length; i++) {
const p0 = percents[i - 1];
const p1 = percents[i];
if (finalKfs[p0][transformOriginKey] !== finalKfs[p1][transformOriginKey]) {
allTransformOriginSame = false;
break;
}
transformOrigin = finalKfs[p0][transformOriginKey];
}
if (allTransformOriginSame && transformOrigin) {
for (const percent in finalKfs) {
if (finalKfs[percent][transformOriginKey]) {
delete finalKfs[percent][transformOriginKey];
}
}
attrs[transformOriginKey] = transformOrigin;
}
if (filter(
percents, (percent) => keys(finalKfs[percent]).length > 0
).length) {
const animationName = addAnimation(finalKfs, scope);
// eslint-disable-next-line
// for (const attrName in finalKfs[percents[0]]) {
// // Remove the attrs in the element because it will be set by animation.
// // Reduce the size.
// attrs[attrName] = false;
// }
// animationName {duration easing delay loop} fillMode
return `${animationName} ${groupAnimator[0]} both`;
}
}
// eslint-disable-next-line
for (let key in groupAnimators) {
const animationCfg = createSingleCSSAnimation(groupAnimators[key]);
if (animationCfg) {
cssAnimations.push(animationCfg);
}
}
if (cssAnimations.length) {
const className = scope.zrId + '-cls-' + getClassId();
scope.cssNodes['.' + className] = {
animation: cssAnimations.join(',')
};
// TODO exists class?
attrs.class = className;
}
}
+5
View File
@@ -0,0 +1,5 @@
let cssClassIdx = 0;
export function getClassId() {
return cssClassIdx++;
}
+73
View File
@@ -0,0 +1,73 @@
import Displayable from '../graphic/Displayable';
import { liftColor } from '../tool/color';
import { BrushScope, SVGVNodeAttrs } from './core';
import { getClassId } from './cssClassId';
export function createCSSEmphasis(
el: Displayable,
attrs: SVGVNodeAttrs,
scope: BrushScope
) {
if (!el.ignore) {
if (el.isSilent()) {
// If el is silent, it can not be hovered nor selected.
// So set pointer-events to pass through.
const style = {
'pointer-events': 'none'
};
setClassAttribute(style, attrs, scope, true);
}
else {
const emphasisStyle = el.states.emphasis && el.states.emphasis.style
? el.states.emphasis.style
: {};
let fill = emphasisStyle.fill;
if (!fill) {
// No empahsis fill, lift color
const normalFill = el.style && el.style.fill;
const selectFill = el.states.select
&& el.states.select.style
&& el.states.select.style.fill;
const fromFill = el.currentStates.indexOf('select') >= 0
? (selectFill || normalFill)
: normalFill;
if (fromFill) {
fill = liftColor(fromFill);
}
}
let lineWidth = emphasisStyle.lineWidth;
if (lineWidth) {
// Symbols use transform to set size, so lineWidth
// should be divided by scaleX
const scaleX = (!emphasisStyle.strokeNoScale && el.transform)
? el.transform[0]
: 1;
lineWidth = lineWidth / scaleX;
}
const style = {
cursor: 'pointer', // TODO: Should this be customized?
} as any;
if (fill) {
style.fill = fill;
}
if (emphasisStyle.stroke) {
style.stroke = emphasisStyle.stroke;
}
if (lineWidth) {
style['stroke-width'] = lineWidth;
}
setClassAttribute(style, attrs, scope, true);
}
}
}
function setClassAttribute(style: object, attrs: SVGVNodeAttrs, scope: BrushScope, withHover: boolean) {
const styleKey = JSON.stringify(style);
let className = scope.cssStyleCache[styleKey];
if (!className) {
className = scope.zrId + '-cls-' + getClassId();
scope.cssStyleCache[styleKey] = className;
scope.cssNodes['.' + className + (withHover ? ':hover' : '')] = style as any;
}
attrs.class = attrs.class ? (attrs.class + ' ' + className) : className;
}
+55
View File
@@ -0,0 +1,55 @@
export function createTextNode(text: string): Text {
return document.createTextNode(text);
}
export function createComment(text: string): Comment {
return document.createComment(text);
}
export function insertBefore(
parentNode: Node,
newNode: Node,
referenceNode: Node | null
): void {
parentNode.insertBefore(newNode, referenceNode);
}
export function removeChild(node: Node, child: Node): void {
node.removeChild(child);
}
export function appendChild(node: Node, child: Node): void {
node.appendChild(child);
}
export function parentNode(node: Node): Node | null {
return node.parentNode;
}
export function nextSibling(node: Node): Node | null {
return node.nextSibling;
}
export function tagName(elm: Element): string {
return elm.tagName;
}
export function setTextContent(node: Node, text: string | null): void {
node.textContent = text;
}
export function getTextContent(node: Node): string | null {
return node.textContent;
}
export function isElement(node: Node): node is Element {
return node.nodeType === 1;
}
export function isText(node: Node): node is Text {
return node.nodeType === 3;
}
export function isComment(node: Node): node is Comment {
return node.nodeType === 8;
}
+661
View File
@@ -0,0 +1,661 @@
// 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 {
attrs[key] = val;
}
if (isFillStroke && scope.ssr && val === 'none') {
// When is none, it cannot be interacted when ssr
// Setting `pointer-events` as `visible` to make it responding
// See also https://www.w3.org/TR/SVG/interact.html#PointerEventsProperty
attrs['pointer-events'] = 'visible';
}
}, 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);
}
+191
View File
@@ -0,0 +1,191 @@
// Shared methods of svg and svg-ssr
import { MatrixArray } from '../core/matrix';
import Transformable, { TransformProp } from '../core/Transformable';
import { RADIAN_TO_DEGREE, retrieve2, logError } from '../core/util';
import Displayable from '../graphic/Displayable';
import { GradientObject } from '../graphic/Gradient';
import { LinearGradientObject } from '../graphic/LinearGradient';
import Path from '../graphic/Path';
import { ImagePatternObject, PatternObject, SVGPatternObject } from '../graphic/Pattern';
import { RadialGradientObject } from '../graphic/RadialGradient';
import { parse } from '../tool/color';
const mathRound = Math.round;
export function normalizeColor(color: string): { color: string; opacity: number; } {
let opacity;
if (!color || color === 'transparent') {
color = 'none';
}
else if (typeof color === 'string' && color.indexOf('rgba') > -1) {
const arr = parse(color);
if (arr) {
// TODO use hex?
color = 'rgb(' + arr[0] + ',' + arr[1] + ',' + arr[2] + ')';
opacity = arr[3];
}
}
return {
color,
opacity: opacity == null ? 1 : opacity
};
}
const EPSILON = 1e-4;
export function isAroundZero(transform: number) {
return transform < EPSILON && transform > -EPSILON;
}
export function round3(transform: number) {
return mathRound(transform * 1e3) / 1e3;
}
export function round4(transform: number) {
return mathRound(transform * 1e4) / 1e4;
}
export function round1(transform: number) {
return mathRound(transform * 10) / 10;
}
export function getMatrixStr(m: MatrixArray) {
return 'matrix('
// Avoid large string of matrix
// PENDING If have precision issue when scaled
+ round3(m[0]) + ','
+ round3(m[1]) + ','
+ round3(m[2]) + ','
+ round3(m[3]) + ','
+ round4(m[4]) + ','
+ round4(m[5])
+ ')';
}
export const TEXT_ALIGN_TO_ANCHOR = {
left: 'start',
right: 'end',
center: 'middle',
middle: 'middle'
};
export function adjustTextY(y: number, lineHeight: number, textBaseline: CanvasTextBaseline): number {
// TODO Other baselines.
if (textBaseline === 'top') {
y += lineHeight / 2;
}
else if (textBaseline === 'bottom') {
y -= lineHeight / 2;
}
return y;
}
export function hasShadow(style: Displayable['style']) {
// TODO: textBoxShadowBlur is not supported yet
return style
&& (style.shadowBlur || style.shadowOffsetX || style.shadowOffsetY);
}
export function getShadowKey(displayable: Displayable) {
const style = displayable.style;
const globalScale = displayable.getGlobalScale();
return [
style.shadowColor,
(style.shadowBlur || 0).toFixed(2), // Reduce the precision
(style.shadowOffsetX || 0).toFixed(2),
(style.shadowOffsetY || 0).toFixed(2),
globalScale[0],
globalScale[1]
].join(',');
}
export function getClipPathsKey(clipPaths: Path[]) {
let key: number[] = [];
if (clipPaths) {
for (let i = 0; i < clipPaths.length; i++) {
const clipPath = clipPaths[i];
key.push(clipPath.id);
}
}
return key.join(',');
}
export function isImagePattern(val: any): val is ImagePatternObject {
return val && (!!(val as ImagePatternObject).image);
}
export function isSVGPattern(val: any): val is SVGPatternObject {
return val && (!!(val as SVGPatternObject).svgElement);
}
export function isPattern(val: any): val is PatternObject {
return isImagePattern(val) || isSVGPattern(val);
}
export function isLinearGradient(val: GradientObject): val is LinearGradientObject {
return val.type === 'linear';
}
export function isRadialGradient(val: GradientObject): val is RadialGradientObject {
return val.type === 'radial';
}
export function isGradient(val: any): val is GradientObject {
return val && (
(val as GradientObject).type === 'linear'
|| (val as GradientObject).type === 'radial'
);
}
export function getIdURL(id: string) {
return `url(#${id})`;
}
export function getPathPrecision(el: Path) {
const scale = el.getGlobalScale();
const size = Math.max(scale[0], scale[1]);
return Math.max(Math.ceil(Math.log(size) / Math.log(10)), 1);
}
export function getSRTTransformString(
transform: Partial<Pick<Transformable, TransformProp>>
) {
const x = transform.x || 0;
const y = transform.y || 0;
const rotation = (transform.rotation || 0) * RADIAN_TO_DEGREE;
const scaleX = retrieve2(transform.scaleX, 1);
const scaleY = retrieve2(transform.scaleY, 1);
const skewX = transform.skewX || 0;
const skewY = transform.skewY || 0;
const res = [];
if (x || y) {
// TODO not using px unit?
res.push(`translate(${x}px,${y}px)`);
}
if (rotation) {
res.push(`rotate(${rotation})`);
}
if (scaleX !== 1 || scaleY !== 1) {
res.push(`scale(${scaleX},${scaleY})`);
}
if (skewX || skewY) {
res.push(`skew(${mathRound(skewX * RADIAN_TO_DEGREE)}deg, ${mathRound(skewY * RADIAN_TO_DEGREE)}deg)`);
}
return res.join(' ');
}
export const encodeBase64 = (function () {
if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
return function (str: string) {
return Buffer.from(str).toString('base64');
};
}
if (typeof btoa === 'function' && typeof unescape === 'function' && typeof encodeURIComponent === 'function') {
return function (str: string) {
return btoa(unescape(encodeURIComponent(str)));
};
}
return function (str: string): string {
if (process.env.NODE_ENV !== 'production') {
logError('Base64 isn\'t natively supported in the current environment.');
}
return null;
};
})();
+110
View File
@@ -0,0 +1,110 @@
import Path, { DEFAULT_PATH_STYLE, PathStyleProps } from '../graphic/Path';
import ZRImage, { ImageStyleProps } from '../graphic/Image';
import TSpan, { TSpanStyleProps } from '../graphic/TSpan';
import { getLineDash } from '../canvas/dashStyle';
import { map } from '../core/util';
import { normalizeColor } from './helper';
type AllStyleOption = PathStyleProps | TSpanStyleProps | ImageStyleProps;
const NONE = 'none';
const mathRound = Math.round;
function pathHasFill(style: AllStyleOption): style is PathStyleProps {
const fill = (style as PathStyleProps).fill;
return fill != null && fill !== NONE;
}
function pathHasStroke(style: AllStyleOption): style is PathStyleProps {
const stroke = (style as PathStyleProps).stroke;
return stroke != null && stroke !== NONE;
}
const strokeProps = ['lineCap', 'miterLimit', 'lineJoin'] as const;
const svgStrokeProps = map(strokeProps, prop => `stroke-${prop.toLowerCase()}`);
export default function mapStyleToAttrs(
updateAttr: (key: string, val: string | number) => void,
style: AllStyleOption,
el: Path | TSpan | ZRImage,
/**
* Will try not to set the attribute if it's using default value if not using forceUpdate.
* Mainly for reduce the generated size in svg-ssr mode.
*/
forceUpdate: boolean
): void {
const opacity = style.opacity == null ? 1 : style.opacity;
// only set opacity. stroke and fill cannot be applied to svg image
if (el instanceof ZRImage) {
updateAttr('opacity', opacity);
return;
}
if (pathHasFill(style)) {
const fill = normalizeColor(style.fill as string);
updateAttr('fill', fill.color);
const fillOpacity = style.fillOpacity != null
? style.fillOpacity * fill.opacity * opacity
: fill.opacity * opacity;
if (forceUpdate || fillOpacity < 1) {
updateAttr('fill-opacity', fillOpacity);
}
}
else {
updateAttr('fill', NONE);
}
if (pathHasStroke(style)) {
const stroke = normalizeColor(style.stroke as string);
updateAttr('stroke', stroke.color);
const strokeScale = style.strokeNoScale
? (el as Path).getLineScale()
: 1;
const strokeWidth = (strokeScale ? (style.lineWidth || 0) / strokeScale : 0);
const strokeOpacity = style.strokeOpacity != null
? style.strokeOpacity * stroke.opacity * opacity
: stroke.opacity * opacity;
const strokeFirst = style.strokeFirst;
if (forceUpdate || strokeWidth !== 1) {
updateAttr('stroke-width', strokeWidth);
}
// stroke then fill for text; fill then stroke for others
if (forceUpdate || strokeFirst) {
updateAttr('paint-order', strokeFirst ? 'stroke' : 'fill');
}
if (forceUpdate || strokeOpacity < 1) {
updateAttr('stroke-opacity', strokeOpacity);
}
if (style.lineDash) {
let [lineDash, lineDashOffset] = getLineDash(el);
if (lineDash) {
lineDashOffset = mathRound(lineDashOffset || 0);
updateAttr('stroke-dasharray', lineDash.join(','));
if (lineDashOffset || forceUpdate) {
updateAttr('stroke-dashoffset', lineDashOffset);
}
}
}
else if (forceUpdate) {
// Reset if force update.
updateAttr('stroke-dasharray', NONE);
}
// PENDING reset
for (let i = 0; i < strokeProps.length; i++) {
const propName = strokeProps[i];
if (forceUpdate || style[propName] !== DEFAULT_PATH_STYLE[propName]) {
const val = style[propName] || DEFAULT_PATH_STYLE[propName];
// TODO reset
val && updateAttr(svgStrokeProps[i], val);
}
}
}
else if (forceUpdate) {
updateAttr('stroke', NONE);
}
}
+321
View File
@@ -0,0 +1,321 @@
/**
* Virtual DOM patching
* Modified from snabbdom https://github.com/snabbdom/snabbdom/blob/master/src/init.ts
*
* The design has been simplified to focus on the purpose in SVG rendering in SVG.
*
* Licensed under the MIT License
* https://github.com/paldepind/snabbdom/blob/master/LICENSE
*/
import { isArray, isObject } from '../core/util';
import { createElement, createVNode, SVGVNode, XMLNS, XML_NAMESPACE, XLINKNS } from './core';
import * as api from './domapi';
const colonChar = 58;
const xChar = 120;
const emptyNode = createVNode('', '');
type NonUndefined<T> = T extends undefined ? never : T;
function isUndef(s: any): boolean {
return s === undefined;
}
function isDef<A>(s: A): s is NonUndefined<A> {
return s !== undefined;
}
function createKeyToOldIdx(
children: SVGVNode[],
beginIdx: number,
endIdx: number
): KeyToIndexMap {
const map: KeyToIndexMap = {};
for (let i = beginIdx; i <= endIdx; ++i) {
const key = children[i].key;
if (key !== undefined) {
if (process.env.NODE_ENV !== 'production') {
if (map[key] != null) {
console.error(`Duplicate key ${key}`);
}
}
map[key] = i;
}
}
return map;
}
function sameVnode(vnode1: SVGVNode, vnode2: SVGVNode): boolean {
const isSameKey = vnode1.key === vnode2.key;
const isSameTag = vnode1.tag === vnode2.tag;
return isSameTag && isSameKey;
}
type KeyToIndexMap = { [key: string]: number };
function createElm(vnode: SVGVNode): Node {
let i: any;
const children = vnode.children;
const tag = vnode.tag;
// if (tag === '!') {
// if (isUndef(vnode.text)) {
// vnode.text = '';
// }
// vnode.elm = api.createComment(vnode.text!);
// }
// else
if (isDef(tag)) {
const elm = (vnode.elm = createElement(tag));
updateAttrs(emptyNode, vnode);
if (isArray(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i];
if (ch != null) {
api.appendChild(elm, createElm(ch));
}
}
}
else if (isDef(vnode.text) && !isObject(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
}
else {
vnode.elm = api.createTextNode(vnode.text!);
}
return vnode.elm;
}
function addVnodes(
parentElm: Node,
before: Node | null,
vnodes: SVGVNode[],
startIdx: number,
endIdx: number
) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (ch != null) {
api.insertBefore(parentElm, createElm(ch), before);
}
}
}
function removeVnodes(parentElm: Node, vnodes: SVGVNode[], startIdx: number, endIdx: number): void {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (ch != null) {
if (isDef(ch.tag)) {
const parent = api.parentNode(ch.elm);
api.removeChild(parent, ch.elm);
}
else {
// Text node
api.removeChild(parentElm, ch.elm!);
}
}
}
}
export function updateAttrs(oldVnode: SVGVNode, vnode: SVGVNode): void {
let key: string;
const elm = vnode.elm as SVGElement;
const oldAttrs = oldVnode && oldVnode.attrs || {};
const attrs = vnode.attrs || {};
if (oldAttrs === attrs) {
return;
}
// update modified attributes, add new attributes
// eslint-disable-next-line
for (key in attrs) {
const cur = attrs[key];
const old = oldAttrs[key];
if (old !== cur) {
if (cur === true) {
elm.setAttribute(key, '');
}
else if (cur === false) {
elm.removeAttribute(key);
}
else {
if (key === 'style') {
elm.style.cssText = cur as string;
}
else if (key.charCodeAt(0) !== xChar) {
elm.setAttribute(key, cur as any);
}
// TODO
else if (key === 'xmlns:xlink' || key === 'xmlns') {
elm.setAttributeNS(XMLNS, key, cur as any);
}
else if (key.charCodeAt(3) === colonChar) {
// Assume xml namespace
elm.setAttributeNS(XML_NAMESPACE, key, cur as any);
}
else if (key.charCodeAt(5) === colonChar) {
// Assume xlink namespace
elm.setAttributeNS(XLINKNS, key, cur as any);
}
else {
elm.setAttribute(key, cur as any);
}
}
}
}
// remove removed attributes
// use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)
// the other option is to remove all attributes with value == undefined
for (key in oldAttrs) {
if (!(key in attrs)) {
elm.removeAttribute(key);
}
}
}
function updateChildren(parentElm: Node, oldCh: SVGVNode[], newCh: SVGVNode[]) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx: KeyToIndexMap | undefined;
let idxInOld: number;
let elmToMove: SVGVNode;
let before: any;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
}
else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
}
else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
}
else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode);
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode);
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
else {
if (isUndef(oldKeyToIdx)) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key];
if (isUndef(idxInOld)) {
// New element
api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm!);
}
else {
elmToMove = oldCh[idxInOld];
if (elmToMove.tag !== newStartVnode.tag) {
api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm!);
}
else {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx);
}
else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
function patchVnode(oldVnode: SVGVNode, vnode: SVGVNode) {
const elm = (vnode.elm = oldVnode.elm)!;
const oldCh = oldVnode.children;
const ch = vnode.children;
if (oldVnode === vnode) {
return;
}
updateAttrs(oldVnode, vnode);
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) {
updateChildren(elm, oldCh, ch);
}
}
else if (isDef(ch)) {
if (isDef(oldVnode.text)) {
api.setTextContent(elm, '');
}
addVnodes(elm, null, ch, 0, ch.length - 1);
}
else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '');
}
}
else if (oldVnode.text !== vnode.text) {
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
api.setTextContent(elm, vnode.text!);
}
}
export default function patch(oldVnode: SVGVNode, vnode: SVGVNode): SVGVNode {
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode);
}
else {
const elm = oldVnode.elm!;
const parent = api.parentNode(elm);
createElm(vnode);
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
return vnode;
}
+4
View File
@@ -0,0 +1,4 @@
import {registerPainter} from '../zrender';
import Painter from './Painter';
registerPainter('svg', Painter);