diff --git a/src/index.ts b/src/index.ts index b81444b..eed0b34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,3 +22,4 @@ export { IEdge, IEdgeBase, IEdgePosition, IEdgeStyle, isEdge, EdgeType } from '. export { IGraphStyle, getDefaultGraphStyle } from './models/style'; export { ICircle, IPosition, IRectangle, Color, IColorRGB } from './common'; export { OrbView, OrbMapView, IOrbView, IOrbMapViewSettings, IOrbViewSettings } from './views'; +export { graphToSVG, ISVGExportOptions } from './renderer/svg'; diff --git a/src/renderer/svg/defs.ts b/src/renderer/svg/defs.ts new file mode 100644 index 0000000..16d35ed --- /dev/null +++ b/src/renderer/svg/defs.ts @@ -0,0 +1,36 @@ +import { IRectangle } from '../../common'; + +export class SVGDefs { + private _idBySignature = new Map(); + private _entries: string[] = []; + private _counter = 0; + private readonly _filterRegion?: IRectangle; + + constructor(filterRegion?: IRectangle) { + this._filterRegion = filterRegion; + } + + get filterRegion(): IRectangle | undefined { + return this._filterRegion; + } + + add(signature: string, build: (id: string) => string): string { + const existing = this._idBySignature.get(signature); + if (existing !== undefined) { + return existing; + } + + const id = `orb-def-${this._counter}`; + this._counter += 1; + this._idBySignature.set(signature, id); + this._entries.push(build(id)); + return id; + } + + toSVG(): string { + if (!this._entries.length) { + return ''; + } + return `${this._entries.join('')}`; + } +} diff --git a/src/renderer/svg/edge.ts b/src/renderer/svg/edge.ts new file mode 100644 index 0000000..2c1a526 --- /dev/null +++ b/src/renderer/svg/edge.ts @@ -0,0 +1,154 @@ +import { INodeBase } from '../../models/node'; +import { IEdge, IEdgeBase, EdgeStraight, EdgeCurved, EdgeLoopback } from '../../models/edge'; +import { IPosition } from '../../common'; +import { LabelTextBaseline } from '../canvas/label'; +import { IEdgeArrow } from '../canvas/edge/shared'; +import { getStraightArrowShape } from '../canvas/edge/types/edge-straight'; +import { getCurvedArrowShape } from '../canvas/edge/types/edge-curved'; +import { getLoopbackArrowShape } from '../canvas/edge/types/edge-loopback'; +import { labelToSVG } from './label'; +import { shadowFilterId, toShadow } from './shadow'; +import { SVGDefs } from './defs'; +import { formatNumber, svgElement, SVGAttributes } from './utils'; + +const DEFAULT_EDGE_COLOR = '#000000'; + +const ARROW_KEY_POINTS: IPosition[] = [ + { x: 0, y: 0 }, + { x: -1, y: 0.4 }, + { x: -1, y: -0.4 }, +]; + +export interface IEdgeToSVGOptions { + isLabelEnabled: boolean; + isShadowEnabled: boolean; +} + +export const edgeToSVG = ( + edge: IEdge, + defs: SVGDefs, + options?: Partial, +): string => { + const width = edge.getWidth(); + if (!width) { + return ''; + } + + const isLabelEnabled = options?.isLabelEnabled ?? true; + const isShadowEnabled = options?.isShadowEnabled ?? true; + const color = (edge.getColor() ?? DEFAULT_EDGE_COLOR).toString(); + + const arrow = edgeArrowToSVG(edge, color); + const line = edgeLineToSVG(edge, width, color); + + const shadow = isShadowEnabled && edge.hasShadow() ? toShadow(edge.getStyle()) : null; + + let content = `${arrow}${line}`; + if (shadow) { + content = svgElement('g', { filter: `url(#${shadowFilterId(defs, shadow)})` }, content); + } + + const label = isLabelEnabled ? edgeLabelToSVG(edge) : ''; + + return svgElement('g', {}, `${content}${label}`); +}; + +const edgeLineToSVG = ( + edge: IEdge, + width: number, + color: string, +): string => { + const dashPattern = edge.getLineDashPattern(); + const strokeAttributes: SVGAttributes = { + stroke: color, + 'stroke-width': width, + fill: 'none', + 'stroke-dasharray': dashPattern ? dashPattern.join(' ') : undefined, + }; + + if (edge instanceof EdgeStraight) { + const source = edge.startNode.getCenter(); + const target = edge.endNode.getCenter(); + const d = `M ${formatNumber(source.x)} ${formatNumber(source.y)} L ${formatNumber(target.x)} ${formatNumber( + target.y, + )}`; + return svgElement('path', { d, ...strokeAttributes }); + } + + if (edge instanceof EdgeCurved) { + const source = edge.startNode.getCenter(); + const target = edge.endNode.getCenter(); + const control = edge.getCurvedControlPoint(); + const d = `M ${formatNumber(source.x)} ${formatNumber(source.y)} Q ${formatNumber(control.x)} ${formatNumber( + control.y, + )} ${formatNumber(target.x)} ${formatNumber(target.y)}`; + return svgElement('path', { d, ...strokeAttributes }); + } + + if (edge instanceof EdgeLoopback) { + const { x, y, radius } = edge.getCircularData(); + return svgElement('circle', { cx: x, cy: y, r: radius, ...strokeAttributes }); + } + + return ''; +}; + +const edgeArrowToSVG = (edge: IEdge, color: string): string => { + if (edge.getStyle().arrowSize === 0) { + return ''; + } + + const arrowShape = getArrowShape(edge); + if (!arrowShape) { + return ''; + } + + const points = transformArrowPoints(ARROW_KEY_POINTS, arrowShape); + const pointsAttribute = points.map((point) => `${formatNumber(point.x)},${formatNumber(point.y)}`).join(' '); + + return svgElement('polygon', { points: pointsAttribute, fill: color }); +}; + +const getArrowShape = (edge: IEdge): IEdgeArrow | null => { + if (edge instanceof EdgeStraight) { + return getStraightArrowShape(edge); + } + if (edge instanceof EdgeCurved) { + return getCurvedArrowShape(edge); + } + if (edge instanceof EdgeLoopback) { + return getLoopbackArrowShape(edge); + } + return null; +}; + +const transformArrowPoints = (points: IPosition[], arrow: IEdgeArrow): IPosition[] => { + return points.map((point) => { + const xt = point.x * Math.cos(arrow.angle) - point.y * Math.sin(arrow.angle); + const yt = point.x * Math.sin(arrow.angle) + point.y * Math.cos(arrow.angle); + return { + x: arrow.point.x + arrow.length * xt, + y: arrow.point.y + arrow.length * yt, + }; + }); +}; + +const edgeLabelToSVG = (edge: IEdge): string => { + const edgeLabel = edge.getLabel(); + if (!edgeLabel) { + return ''; + } + + const edgeStyle = edge.getStyle(); + + return labelToSVG(edgeLabel, { + position: edge.getCenter(), + textBaseline: LabelTextBaseline.MIDDLE, + properties: { + fontBackgroundColor: edgeStyle.fontBackgroundColor, + fontColor: edgeStyle.fontColor, + fontFamily: edgeStyle.fontFamily, + fontSize: edgeStyle.fontSize, + }, + }); +}; diff --git a/src/renderer/svg/image.ts b/src/renderer/svg/image.ts new file mode 100644 index 0000000..c7e69e4 --- /dev/null +++ b/src/renderer/svg/image.ts @@ -0,0 +1,78 @@ +import { INodeBase, INode } from '../../models/node'; +import { IEdgeBase } from '../../models/edge'; +import { SVGDefs } from './defs'; +import { ISVGShape } from './shapes'; +import { svgElement } from './utils'; + +export const nodeImageToSVG = ( + node: INode, + defs: SVGDefs, + shape: ISVGShape, +): string => { + const image = node.getBackgroundImage(); + if (!image || !image.width || !image.height) { + return ''; + } + + const href = resolveImageHref(node, image); + if (!href) { + return ''; + } + + const center = node.getCenter(); + const radius = node.getRadius(); + + const clipGeometry = Object.keys(shape.attributes) + .map((key) => `${key}=${shape.attributes[key]}`) + .join(','); + const clipId = defs.add(`clip:${shape.tag}:${clipGeometry}`, (id) => + svgElement('clipPath', { id }, svgElement(shape.tag, shape.attributes)), + ); + + return svgElement('image', { + href, + 'xlink:href': href, + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2, + preserveAspectRatio: 'xMidYMid slice', + 'clip-path': `url(#${clipId})`, + }); +}; + +const resolveImageHref = ( + node: INode, + image: HTMLImageElement, +): string | undefined => { + const dataUrl = imageToDataURL(image); + if (dataUrl) { + return dataUrl; + } + + const style = node.getStyle(); + if (node.isSelected() && style.imageUrlSelected) { + return style.imageUrlSelected; + } + return style.imageUrl ?? image.src ?? undefined; +}; + +const imageToDataURL = (image: HTMLImageElement): string | undefined => { + if (typeof document === 'undefined') { + return undefined; + } + + try { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + const context = canvas.getContext('2d'); + if (!context) { + return undefined; + } + context.drawImage(image, 0, 0); + return canvas.toDataURL(); + } catch { + return undefined; + } +}; diff --git a/src/renderer/svg/index.ts b/src/renderer/svg/index.ts new file mode 100644 index 0000000..80afb92 --- /dev/null +++ b/src/renderer/svg/index.ts @@ -0,0 +1,199 @@ +import { INode, INodeBase } from '../../models/node'; +import { IEdge, IEdgeBase, EdgeCurved, EdgeLoopback } from '../../models/edge'; +import { IGraph } from '../../models/graph'; +import { Color, IRectangle } from '../../common'; +import { nodeToSVG } from './node'; +import { edgeToSVG } from './edge'; +import { SVGDefs } from './defs'; +import { formatNumber, svgElement } from './utils'; + +const DEFAULT_PADDING = 20; +const DEFAULT_FONT_SIZE = 4; +const LABEL_DISTANCE_RATIO = 0.2; +const FONT_LINE_SPACING = 1.2; +const FONT_BACKGROUND_MARGIN = 0.12; +const AVERAGE_GLYPH_WIDTH_RATIO = 0.6; + +export interface ISVGExportOptions { + backgroundColor?: Color | string | null; + padding?: number; + isLabelEnabled?: boolean; + isShadowEnabled?: boolean; + isImageEnabled?: boolean; +} + +export const graphToSVG = ( + graph: IGraph, + options: ISVGExportOptions = {}, +): string => { + const padding = options.padding ?? DEFAULT_PADDING; + const isLabelEnabled = options.isLabelEnabled ?? true; + const isShadowEnabled = options.isShadowEnabled ?? true; + const isImageEnabled = options.isImageEnabled ?? true; + + const nodes = graph.getNodes(); + const edges = graph.getEdges(); + + const content = computeContentBounds(nodes, edges, isLabelEnabled, isShadowEnabled); + const minX = content.x - padding; + const minY = content.y - padding; + const width = Math.max(content.width + padding * 2, 1); + const height = Math.max(content.height + padding * 2, 1); + const region: IRectangle = { x: minX, y: minY, width, height }; + + const defs = new SVGDefs(region); + const body: string[] = []; + + if (options.backgroundColor) { + body.push(svgElement('rect', { x: minX, y: minY, width, height, fill: options.backgroundColor.toString() })); + } + + for (let i = 0; i < edges.length; i++) { + body.push(edgeToSVG(edges[i], defs, { isLabelEnabled, isShadowEnabled })); + } + for (let i = 0; i < nodes.length; i++) { + body.push(nodeToSVG(nodes[i], defs, { isLabelEnabled, isShadowEnabled, isImageEnabled })); + } + + const viewBox = `${formatNumber(minX)} ${formatNumber(minY)} ${formatNumber(width)} ${formatNumber(height)}`; + + return svgElement( + 'svg', + { + xmlns: 'http://www.w3.org/2000/svg', + 'xmlns:xlink': 'http://www.w3.org/1999/xlink', + viewBox, + width, + height, + }, + `${defs.toSVG()}${body.join('')}`, + ); +}; + +interface IMutableBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +const computeContentBounds = ( + nodes: INode[], + edges: IEdge[], + isLabelEnabled: boolean, + isShadowEnabled: boolean, +): IRectangle => { + const bounds: IMutableBounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + + for (let i = 0; i < nodes.length; i++) { + includeNode(bounds, nodes[i], isLabelEnabled, isShadowEnabled); + } + for (let i = 0; i < edges.length; i++) { + includeEdge(bounds, edges[i], isLabelEnabled, isShadowEnabled); + } + + if (!isFinite(bounds.minX)) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + return { x: bounds.minX, y: bounds.minY, width: bounds.maxX - bounds.minX, height: bounds.maxY - bounds.minY }; +}; + +const includePoint = (bounds: IMutableBounds, x: number, y: number): void => { + if (x < bounds.minX) { + bounds.minX = x; + } + if (y < bounds.minY) { + bounds.minY = y; + } + if (x > bounds.maxX) { + bounds.maxX = x; + } + if (y > bounds.maxY) { + bounds.maxY = y; + } +}; + +const includeBox = (bounds: IMutableBounds, cx: number, cy: number, halfWidth: number, halfHeight: number): void => { + includePoint(bounds, cx - halfWidth, cy - halfHeight); + includePoint(bounds, cx + halfWidth, cy + halfHeight); +}; + +const includeNode = ( + bounds: IMutableBounds, + node: INode, + isLabelEnabled: boolean, + isShadowEnabled: boolean, +): void => { + if (node.getRadius() <= 0) { + return; + } + + const center = node.getCenter(); + const radius = node.getBorderedRadius(); + const pad = isShadowEnabled ? shadowReach(node.hasShadow(), node.getStyle()) : 0; + includeBox(bounds, center.x, center.y, radius + pad, radius + pad); + + if (isLabelEnabled && node.getLabel()) { + const style = node.getStyle(); + const top = center.y + node.getBorderedRadius() * (1 + LABEL_DISTANCE_RATIO); + includeLabelBox(bounds, node.getLabel() as string, center.x, top, style.fontSize ?? DEFAULT_FONT_SIZE, false); + } +}; + +const includeEdge = ( + bounds: IMutableBounds, + edge: IEdge, + isLabelEnabled: boolean, + isShadowEnabled: boolean, +): void => { + if (!edge.getWidth()) { + return; + } + + const style = edge.getStyle(); + const pad = isShadowEnabled ? shadowReach(edge.hasShadow(), style) : 0; + + if (edge instanceof EdgeLoopback) { + const circle = edge.getCircularData(); + includeBox(bounds, circle.x, circle.y, circle.radius + pad, circle.radius + pad); + } else if (edge instanceof EdgeCurved) { + const control = edge.getCurvedControlPoint(); + includeBox(bounds, control.x, control.y, pad, pad); + } + + if (isLabelEnabled && edge.getLabel()) { + const center = edge.getCenter(); + includeLabelBox(bounds, edge.getLabel() as string, center.x, center.y, style.fontSize ?? DEFAULT_FONT_SIZE, true); + } +}; + +const includeLabelBox = ( + bounds: IMutableBounds, + text: string, + cx: number, + y: number, + fontSize: number, + isCentered: boolean, +): void => { + if (fontSize <= 0) { + return; + } + const lines = `${text}`.split('\n'); + const maxLength = lines.reduce((max, line) => Math.max(max, line.trim().length), 0); + const margin = fontSize * FONT_BACKGROUND_MARGIN; + const width = maxLength * fontSize * AVERAGE_GLYPH_WIDTH_RATIO + 2 * margin; + const height = lines.length * fontSize * FONT_LINE_SPACING + 2 * margin; + const top = isCentered ? y - height / 2 : y; + includePoint(bounds, cx - width / 2, top); + includePoint(bounds, cx + width / 2, top + height); +}; + +const shadowReach = ( + hasShadow: boolean, + style: { shadowColor?: Color | string; shadowSize?: number; shadowOffsetX?: number; shadowOffsetY?: number }, +): number => { + if (!hasShadow || !style.shadowColor) { + return 0; + } + return (style.shadowSize ?? 0) + Math.max(Math.abs(style.shadowOffsetX ?? 0), Math.abs(style.shadowOffsetY ?? 0)); +}; diff --git a/src/renderer/svg/label.ts b/src/renderer/svg/label.ts new file mode 100644 index 0000000..3a52a79 --- /dev/null +++ b/src/renderer/svg/label.ts @@ -0,0 +1,83 @@ +import { IPosition } from '../../common'; +import { Label, LabelTextBaseline, ILabelProperties } from '../canvas/label'; +import { escapeXML, svgElement } from './utils'; + +const DEFAULT_FONT_FAMILY = 'Roboto, sans-serif'; +const DEFAULT_FONT_COLOR = '#000000'; +const FONT_LINE_SPACING = 1.2; +const FONT_BACKGROUND_MARGIN = 0.12; +const AVERAGE_GLYPH_WIDTH_RATIO = 0.6; + +export interface ILabelToSVGData { + position: IPosition; + textBaseline: LabelTextBaseline; + properties: Partial; +} + +export const labelToSVG = (text: string | undefined, data: ILabelToSVGData): string => { + if (text === undefined || text === null || `${text}` === '') { + return ''; + } + + const label = new Label(text, { + position: data.position, + textBaseline: data.textBaseline, + properties: data.properties, + }); + + if (!label.textLines.length || label.fontSize <= 0) { + return ''; + } + + const fontFamily = data.properties.fontFamily ?? DEFAULT_FONT_FAMILY; + const fontColor = (data.properties.fontColor ?? DEFAULT_FONT_COLOR).toString(); + const lineHeight = label.fontSize * FONT_LINE_SPACING; + const dominantBaseline = data.textBaseline === LabelTextBaseline.MIDDLE ? 'middle' : 'text-before-edge'; + + const background = labelBackgroundToSVG(label, lineHeight); + + const tspans = label.textLines + .map((line, i) => svgElement('tspan', { x: label.position.x, dy: i === 0 ? 0 : lineHeight }, escapeXML(line))) + .join(''); + + const textElement = svgElement( + 'text', + { + x: label.position.x, + y: label.position.y, + 'font-size': label.fontSize, + 'font-family': fontFamily, + fill: fontColor, + 'text-anchor': 'middle', + 'dominant-baseline': dominantBaseline, + }, + tspans, + ); + + return `${background}${textElement}`; +}; + +const labelBackgroundToSVG = (label: Label, lineHeight: number): string => { + const backgroundColor = label.properties.fontBackgroundColor; + if (!backgroundColor) { + return ''; + } + + const margin = label.fontSize * FONT_BACKGROUND_MARGIN; + const height = label.fontSize + 2 * margin; + const baselineHeight = label.textBaseline === LabelTextBaseline.MIDDLE ? label.fontSize / 2 : 0; + const color = backgroundColor.toString(); + + return label.textLines + .map((line, i) => { + const width = line.length * label.fontSize * AVERAGE_GLYPH_WIDTH_RATIO + 2 * margin; + return svgElement('rect', { + x: label.position.x - width / 2, + y: label.position.y - baselineHeight - margin + i * lineHeight, + width, + height, + fill: color, + }); + }) + .join(''); +}; diff --git a/src/renderer/svg/node.ts b/src/renderer/svg/node.ts new file mode 100644 index 0000000..1d283ff --- /dev/null +++ b/src/renderer/svg/node.ts @@ -0,0 +1,87 @@ +import { INodeBase, INode } from '../../models/node'; +import { IEdgeBase } from '../../models/edge'; +import { LabelTextBaseline } from '../canvas/label'; +import { shapeToSVGShape } from './shapes'; +import { labelToSVG } from './label'; +import { nodeImageToSVG } from './image'; +import { shadowFilterId, toShadow } from './shadow'; +import { SVGDefs } from './defs'; +import { svgElement, SVGAttributes } from './utils'; + +const DEFAULT_LABEL_DISTANCE_SIZE_FROM_NODE = 0.2; +const DEFAULT_NODE_COLOR = '#000000'; +const DEFAULT_BORDER_COLOR = '#000000'; + +export interface INodeToSVGOptions { + isLabelEnabled: boolean; + isShadowEnabled: boolean; + isImageEnabled: boolean; +} + +export const nodeToSVG = ( + node: INode, + defs: SVGDefs, + options?: Partial, +): string => { + const isLabelEnabled = options?.isLabelEnabled ?? true; + const isShadowEnabled = options?.isShadowEnabled ?? true; + const isImageEnabled = options?.isImageEnabled ?? true; + + const center = node.getCenter(); + const radius = node.getRadius(); + if (radius <= 0) { + return ''; + } + + const shape = shapeToSVGShape(node.getStyle().shape, center.x, center.y, radius); + const color = (node.getColor() ?? DEFAULT_NODE_COLOR).toString(); + + const hasBorder = node.hasBorder(); + const borderAttributes: SVGAttributes = {}; + if (hasBorder) { + borderAttributes.stroke = node.getBorderColor()?.toString() ?? DEFAULT_BORDER_COLOR; + borderAttributes['stroke-width'] = node.getBorderWidth(); + } + + const image = isImageEnabled ? nodeImageToSVG(node, defs, shape) : ''; + const shadow = isShadowEnabled && node.hasShadow() ? toShadow(node.getStyle()) : null; + + let content: string; + if (!image && !shadow) { + content = svgElement(shape.tag, { ...shape.attributes, fill: color, ...borderAttributes }); + } else { + const fill = svgElement(shape.tag, { ...shape.attributes, fill: color }); + let shadowed = `${fill}${image}`; + if (shadow) { + shadowed = svgElement('g', { filter: `url(#${shadowFilterId(defs, shadow)})` }, shadowed); + } + const border = hasBorder ? svgElement(shape.tag, { ...shape.attributes, fill: 'none', ...borderAttributes }) : ''; + content = `${shadowed}${border}`; + } + + const label = isLabelEnabled ? nodeLabelToSVG(node) : ''; + + return svgElement('g', {}, `${content}${label}`); +}; + +const nodeLabelToSVG = (node: INode): string => { + const nodeLabel = node.getLabel(); + if (!nodeLabel) { + return ''; + } + + const center = node.getCenter(); + const distance = node.getBorderedRadius() * (1 + DEFAULT_LABEL_DISTANCE_SIZE_FROM_NODE); + const nodeStyle = node.getStyle(); + + return labelToSVG(nodeLabel, { + position: { x: center.x, y: center.y + distance }, + textBaseline: LabelTextBaseline.TOP, + properties: { + fontBackgroundColor: nodeStyle.fontBackgroundColor, + fontColor: nodeStyle.fontColor, + fontFamily: nodeStyle.fontFamily, + fontSize: nodeStyle.fontSize, + }, + }); +}; diff --git a/src/renderer/svg/shadow.ts b/src/renderer/svg/shadow.ts new file mode 100644 index 0000000..b87b0bb --- /dev/null +++ b/src/renderer/svg/shadow.ts @@ -0,0 +1,87 @@ +import { Color, IRectangle } from '../../common'; +import { INodeStyle } from '../../models/node'; +import { IEdgeStyle } from '../../models/edge'; +import { SVGDefs } from './defs'; +import { escapeXML, formatNumber } from './utils'; + +const SHADOW_STD_DEVIATION_RATIO = 0.5; + +export interface IShadow { + color: Color | string; + size: number; + offsetX: number; + offsetY: number; +} + +export const toShadow = (style: INodeStyle | IEdgeStyle): IShadow | null => { + if (!style.shadowColor) { + return null; + } + return { + color: style.shadowColor, + size: style.shadowSize ?? 0, + offsetX: style.shadowOffsetX ?? 0, + offsetY: style.shadowOffsetY ?? 0, + }; +}; + +export const shadowFilterId = (defs: SVGDefs, shadow: IShadow): string => { + const { color, opacity } = parseColorAlpha(shadow.color.toString()); + const stdDeviation = Math.max(shadow.size * SHADOW_STD_DEVIATION_RATIO, 0); + const signature = `shadow:${color}:${opacity}:${stdDeviation}:${shadow.offsetX}:${shadow.offsetY}`; + + return defs.add(signature, (id) => + buildShadowFilter(id, color, opacity, stdDeviation, shadow.offsetX, shadow.offsetY, defs.filterRegion), + ); +}; + +const buildShadowFilter = ( + id: string, + color: string, + opacity: number, + stdDeviation: number, + offsetX: number, + offsetY: number, + region?: IRectangle, +): string => { + const regionAttributes = region + ? `filterUnits="userSpaceOnUse" x="${formatNumber(region.x)}" y="${formatNumber(region.y)}" ` + + `width="${formatNumber(region.width)}" height="${formatNumber(region.height)}"` + : 'filterUnits="objectBoundingBox" x="-50%" y="-50%" width="200%" height="200%"'; + + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); +}; + +const parseColorAlpha = (color: string): { color: string; opacity: number } => { + const rgba = color.match(/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/i); + if (rgba) { + return { color: `rgb(${rgba[1]}, ${rgba[2]}, ${rgba[3]})`, opacity: clamp01(Number(rgba[4])) }; + } + + const hex8 = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); + if (hex8) { + return { color: `#${hex8[1]}${hex8[2]}${hex8[3]}`, opacity: parseInt(hex8[4], 16) / 255 }; + } + + const hex4 = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])$/i); + if (hex4) { + return { color: `#${hex4[1]}${hex4[2]}${hex4[3]}`, opacity: parseInt(hex4[4], 16) / 15 }; + } + + return { color, opacity: 1 }; +}; + +const clamp01 = (value: number): number => { + if (!isFinite(value)) { + return 1; + } + return Math.min(Math.max(value, 0), 1); +}; diff --git a/src/renderer/svg/shapes.ts b/src/renderer/svg/shapes.ts new file mode 100644 index 0000000..adc0112 --- /dev/null +++ b/src/renderer/svg/shapes.ts @@ -0,0 +1,94 @@ +import { NodeShapeType } from '../../models/node'; +import { IPosition } from '../../common'; +import { formatNumber, SVGAttributes } from './utils'; + +export interface ISVGShape { + tag: string; + attributes: SVGAttributes; +} + +const toPointsAttribute = (points: IPosition[]): string => { + return points.map((point) => `${formatNumber(point.x)},${formatNumber(point.y)}`).join(' '); +}; + +const polygon = (points: IPosition[]): ISVGShape => { + return { tag: 'polygon', attributes: { points: toPointsAttribute(points) } }; +}; + +export const shapeToSVGShape = (shape: NodeShapeType | undefined, x: number, y: number, r: number): ISVGShape => { + switch (shape) { + case NodeShapeType.SQUARE: + return { tag: 'rect', attributes: { x: x - r, y: y - r, width: r * 2, height: r * 2 } }; + case NodeShapeType.DIAMOND: + return polygon([ + { x, y: y + r }, + { x: x + r, y }, + { x, y: y - r }, + { x: x - r, y }, + ]); + case NodeShapeType.TRIANGLE: + return polygon(triangleUpPoints(x, y, r)); + case NodeShapeType.TRIANGLE_DOWN: + return polygon(triangleDownPoints(x, y, r)); + case NodeShapeType.STAR: + return polygon(starPoints(x, y, r)); + case NodeShapeType.HEXAGON: + return polygon(ngonPoints(x, y, r, 6)); + default: + return { tag: 'circle', attributes: { cx: x, cy: y, r } }; + } +}; + +const triangleUpPoints = (x: number, y: number, r: number): IPosition[] => { + r *= 1.15; + y += 0.275 * r; + + const diameter = r * 2; + const innerRadius = (Math.sqrt(3) * diameter) / 6; + const height = Math.sqrt(diameter * diameter - r * r); + + return [ + { x, y: y - (height - innerRadius) }, + { x: x + r, y: y + innerRadius }, + { x: x - r, y: y + innerRadius }, + ]; +}; + +const triangleDownPoints = (x: number, y: number, r: number): IPosition[] => { + r *= 1.15; + y -= 0.275 * r; + + const diameter = r * 2; + const innerRadius = (Math.sqrt(3) * diameter) / 6; + const height = Math.sqrt(diameter * diameter - r * r); + + return [ + { x, y: y + (height - innerRadius) }, + { x: x + r, y: y - innerRadius }, + { x: x - r, y: y - innerRadius }, + ]; +}; + +const starPoints = (x: number, y: number, r: number): IPosition[] => { + r *= 0.82; + y += 0.1 * r; + + const points: IPosition[] = []; + for (let n = 0; n < 10; n++) { + const radius = r * (n % 2 === 0 ? 1.3 : 0.5); + points.push({ + x: x + radius * Math.sin((n * 2 * Math.PI) / 10), + y: y - radius * Math.cos((n * 2 * Math.PI) / 10), + }); + } + return points; +}; + +const ngonPoints = (x: number, y: number, r: number, sides: number): IPosition[] => { + const points: IPosition[] = []; + const arcSide = (Math.PI * 2) / sides; + for (let i = 0; i < sides; i++) { + points.push({ x: x + r * Math.cos(arcSide * i), y: y + r * Math.sin(arcSide * i) }); + } + return points; +}; diff --git a/src/renderer/svg/utils.ts b/src/renderer/svg/utils.ts new file mode 100644 index 0000000..71352e0 --- /dev/null +++ b/src/renderer/svg/utils.ts @@ -0,0 +1,37 @@ +export type SVGAttributes = Record; + +export const formatNumber = (value: number): string => { + if (!isFinite(value)) { + return '0'; + } + return `${Math.round(value * 1000) / 1000}`; +}; + +export const escapeXML = (value: string): string => { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +export const svgElement = (tag: string, attributes: SVGAttributes, children?: string): string => { + const serializedAttributes = Object.keys(attributes) + .filter((key) => { + const value = attributes[key]; + return value !== undefined && value !== null && value !== ''; + }) + .map((key) => { + const value = attributes[key]; + const serializedValue = typeof value === 'number' ? formatNumber(value) : escapeXML(String(value)); + return `${key}="${serializedValue}"`; + }) + .join(' '); + + const openTag = serializedAttributes ? `${tag} ${serializedAttributes}` : tag; + if (children === undefined) { + return `<${openTag}/>`; + } + return `<${openTag}>${children}`; +}; diff --git a/src/views/orb-map-view.ts b/src/views/orb-map-view.ts index 91b0f4f..81fbe50 100644 --- a/src/views/orb-map-view.ts +++ b/src/views/orb-map-view.ts @@ -231,6 +231,10 @@ export class OrbMapView implements IOr onRendered?.(); } + getSVG(): string { + throw new Error('Method not implemented.'); + } + destroy() { this._renderer.destroy(); this._leaflet.off(); diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts index 532d38e..3ccb1fc 100644 --- a/src/views/orb-view.ts +++ b/src/views/orb-view.ts @@ -24,6 +24,7 @@ import { IFitZoomTransformOptions, } from '../renderer/shared'; import { RendererFactory } from '../renderer/factory'; +import { graphToSVG, ISVGExportOptions } from '../renderer/svg'; import { SimulatorEventType } from '../simulator/shared'; import { getDefaultGraphStyle } from '../models/style'; import { isBoolean } from '../utils/type.utils'; @@ -315,6 +316,13 @@ export class OrbView implements IOrbVi .on('end', () => this.render(onRendered)); } + getSVG(options?: ISVGExportOptions): string { + return graphToSVG(this._graph, { + backgroundColor: this._settings.render.backgroundColor, + ...options, + }); + } + destroy() { this._renderer.destroy(); this._simulator.terminate(); diff --git a/src/views/shared.ts b/src/views/shared.ts index 4578dde..b82bba3 100644 --- a/src/views/shared.ts +++ b/src/views/shared.ts @@ -3,6 +3,7 @@ import { IEdgeBase } from '../models/edge'; import { IGraph } from '../models/graph'; import { OrbEmitter } from '../events'; import { IGraphInteraction } from '../models/interaction'; +import { ISVGExportOptions } from '../renderer/svg'; export interface IOrbView { data: IGraph; @@ -12,5 +13,6 @@ export interface IOrbView { setSettings(settings: Partial): void; render(onRendered?: () => void): void; recenter(onRendered?: () => void): void; + getSVG(options?: ISVGExportOptions): string; destroy(): void; }