UNPKG

x4js

Version:

X4 framework

748 lines (576 loc) 14.9 kB
/** * ___ ___ __ * \ \/ / / _ * \ / /_| |_ * / \____ _| * /__/\__\ |_| * * @file core_svg.ts * @author Etienne Cochard * * @copyright (c) 2024 R-libre ingenierie * * Use of this source code is governed by an MIT-style license * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT. **/ import { Component, ComponentProps } from './component'; import { isUnitLess } from "./core_styles"; import { DOMEventHandler, GlobalDOMEvents, addEvent } from './core_dom'; import { isNumber, isString } from './core_tools'; const SVG_NS = "http://www.w3.org/2000/svg"; // degrees to radian function d2r( d: number ): number { return d * Math.PI / 180.0; } // polar to cartesian function p2c( x: number, y: number, r: number, deg: number ): {x: number,y: number} { const rad = d2r( deg ); return { x: x + r * Math.cos( rad ), y: y + r * Math.sin( rad ) }; } // fix prec for numbers function num( x: number ): number { return Math.round( x * 1000 ) / 1000; } // clean values function clean( a: any, ...b: any ) { // just round number values to 3 digits b = b.map( ( v: any ) => { if( typeof v === 'number' && isFinite(v) ) { return num(v); } return v; }); return String.raw( a, ...b ); } class SvgItem { protected _dom : SVGElement; constructor( tag: string ) { this._dom = document.createElementNS("http://www.w3.org/2000/svg", tag ); } /** * @returns the svh element dom */ getDom( ) { return this._dom; } /** * */ reset( ) { const attrs = this._dom.attributes; for (let i = attrs.length - 1; i >= 0; i--) { this._dom.removeAttribute(attrs[i].name); } return this; } /** * change the stroke color * @param color */ stroke( color: string, width?: number ): this { this.setAttr( 'stroke', color ); if( width!==undefined ) { this.setAttr( 'stroke-width', width+'px' ); } return this; } /** * change the stroke width * @param width */ strokeWidth( width: number ): this { this.setAttr( 'stroke-width', width+'px' ); return this; } strokeCap( cap: "butt" | "round" | "sqaure" ) { return this.setAttr( "stroke-linecap", cap ); } strokeOpacity( opacity: number ) { return this.setAttr( "stroke-opacity", opacity+"" ); } /** * */ antiAlias( set: boolean ) { return this.setAttr( "shape-rendering", set ? "auto" : "crispEdges" ); } /** * change the fill color * @param color */ fill( color: string ): this { this.setAttr( 'fill', color ); return this; } no_fill( ): this { this.setAttr( 'fill', "transparent" ); return this; } /** * return the given attribute if any */ getAttr( name: string ) : string { const a = this._dom.getAttribute( name ) || ''; return a; } getNumAttr( name: string ) { const a = this._dom.getAttribute( name ) if( a=='' ) { return 0; } return parseInt( a ); } /** * define a new attribute * @param name attibute name * @param value attribute value * @returns this */ setAttr( name: string, value: string ) : this { if( value===null || value===undefined ) { this._dom.removeAttribute( name ); } else { this._dom.setAttribute( name, value ); } return this; } /** * */ setStyle<K extends keyof CSSStyleDeclaration>( name: K, value: string | number ) : this { const _style = this._dom.style; if( isNumber(value) ) { let v = value+""; if( !isUnitLess(name as string) ) { v += "px"; } (_style as any)[name] = v; } else { (_style as any)[name] = value; } return this; } /** * add a class * @param name class name to add */ addClass( cls: string ): this { if( !cls ) return; cls = cls.trim(); if( cls.indexOf(' ')>=0 ) { const ccs = cls.split( " " ); this._dom.classList.add(...ccs); } else { this._dom.classList.add(cls); } return this; } /** * remove a class * @param name class name to remove */ removeClass( cls: string ): this { if( !cls ) return; if( cls.indexOf(' ')>=0 ) { const ccs = cls.split( " " ); this._dom.classList.remove(...ccs); } else { this._dom.classList.remove(cls); } return this; } /** * */ clip( id: string ): this { this.setAttr( "clip-path", `url(#${id})` ); return this; } /** * */ transform( tr: string ): this { this.setAttr( "transform", tr ); return this; } add_transformation( tr: string ): this { const t = this.getAttr( "transform" ); this.setAttr( "transform", t+' '+tr ); return this; } clear_transform( ) { this.setAttr( "transform", null ); return this; } /** * */ rotate( deg: number, cx: number, cy: number ): this { this.transform( `rotate( ${deg} ${cx} ${cy} )` ); return this; } add_rotation( deg: number, cx: number, cy: number ): this { this.add_transformation( `rotate( ${deg} ${cx} ${cy} )` ); return this; } translate( dx: number, dy: number ): this { this.transform( `translate( ${dx} ${dy} )` ); return this; } add_translation( dx: number, dy: number ): this { this.add_transformation( `translate( ${dx} ${dy} )` ); return this; } scale( x: number ): this { this.transform( `scale( ${x} )` ); return this; } add_scale( x: number ): this { this.add_transformation( `scale( ${x} )` ); return this; } /** * */ addDOMEvent<K extends keyof GlobalDOMEvents>( name: K, listener: GlobalDOMEvents[K], prepend = false ) { addEvent( this._dom, name, listener as DOMEventHandler, prepend ); return this; } } /** * */ export class SvgPath extends SvgItem { private _path: string; constructor( ) { super( 'path' ); this._path = ''; } private _update( ): this { this.setAttr( 'd', this._path ); return this; } reset( ) { this._path = ""; super.reset( ); return this; } /** * move the current pos * @param x new pos x * @param y new pos y * @returns this */ moveTo( x: number, y: number ) : this { this._path += clean`M${x},${y}`; return this._update( ); } /** * draw aline to the given point * @param x end x * @param y end y * @returns this */ lineTo( x: number, y: number ): this { this._path += clean`L${x},${y}`; return this._update( ); } /** * draw a curve */ curveTo( x1: number, y1: number, x2: number, y2: number, x3: number, y3: number ) { this._path += clean`C${x1},${y1} ${x2},${y2} ${x3},${y3}`; return this._update( ); } /** * close the currentPath */ closePath( ): this { this._path += 'Z'; return this._update( ); } /** * draw an arc * @param x center x * @param y center y * @param r radius * @param start angle start in degrees * @param end angle end in degrees * @returns this */ arc( x: number, y: number, r: number, start: number, end: number, clockwise= true ): this { const st = p2c( x, y, r, start-90 ); const en = p2c( x, y, r, end-90 ); const flag = ((end-start) <= 180 ? "0" : "1"); this._path += clean`M${st.x},${st.y}A${r},${r} 0 ${flag} ${(clockwise ? '1' : '0')} ${en.x},${en.y}`; return this._update( ); } } /** * */ export class SvgText extends SvgItem { constructor( x: number, y: number, txt: string ) { super( 'text' ); this.setAttr( 'x', num(x)+'' ); this.setAttr( 'y', num(y)+'' ); this._dom.innerHTML = txt; } font( font: string ): this { return this.setAttr( 'font-family', font ); } fontSize( size: number | string ): this { return this.setAttr( 'font-size', size+'' ); } fontWeight( weight: 'light' | 'normal' | 'bold' ): this { return this.setAttr( 'font-weight', weight ); } textAlign( align: 'left' | 'center' | 'right' ): this { let al; switch( align ) { case 'left': al = 'start'; break; case 'center': al = 'middle'; break; case 'right': al = 'end'; break; default: return this; } return this.setAttr( 'text-anchor', al ); } verticalAlign( align: 'top' | 'center' | 'bottom' | 'baseline' ): this { let al; switch( align ) { case 'top': al = 'hanging'; break; case 'center': al = 'middle'; break; case 'bottom': al = 'baseline'; break; case 'baseline': al = 'mathematical'; break; default: return; } return this.setAttr( 'alignment-baseline', al ); } } /** * */ export class SvgIcon extends SvgItem { constructor( svg: string ) { super( "svg" ); if( svg.startsWith("data:image/svg+xml,") ) { svg = svg.substring( 19 ); } const parser = new DOMParser(); const doc = parser.parseFromString( decodeURIComponent(svg), "image/svg+xml"); const parserErrorElement = doc.querySelector("parsererror"); if( parserErrorElement ) { console.error( "error while parsing svg:\n"+ parserErrorElement.textContent ); } const svgRoot = doc.documentElement; // The <svg> element from the string for( let i=0; i<svgRoot.attributes.length; i++) { this._dom.setAttribute( svgRoot.attributes[i].name, svgRoot.attributes[i].value ); } for( let i=0; i<svgRoot.childNodes.length; i++) { const child = svgRoot.childNodes[i]; if (child.nodeType === 1) { this._dom.appendChild(child); } } } } /** * */ export class SvgShape extends SvgItem { constructor( tag: string ) { super( tag ); } } /** * */ type number_or_perc = number | `${string}%` export class SvgGradient extends SvgItem { private static g_id = 1; private _id: string; private _stops: { offset: number_or_perc, color: string } []; constructor( x1: number_or_perc, y1: number_or_perc, x2: number_or_perc, y2: number_or_perc ) { super( 'linearGradient') this._id = 'gx-'+SvgGradient.g_id; SvgGradient.g_id++; this.setAttr( 'id', this._id ); this.setAttr( 'x1', isString(x1) ? x1 : num(x1)+'' ); this.setAttr( 'x2', isString(x2) ? x2 : num(x2)+'' ); this.setAttr( 'y1', isString(y1) ? y1 : num(y1)+'' ); this.setAttr( 'y2', isString(y2) ? y2 : num(y2)+'' ); this._stops = []; } get id( ) { return 'url(#'+this._id+')'; } addStop( offset: number_or_perc, color: string ): this { this._dom.insertAdjacentHTML( "beforeend", `<stop offset="${offset}%" stop-color="${color}"></stop>`); return this; } } /** * */ export class SvgGroup extends SvgItem { constructor( tag = "g" ) { super( tag ) } /** * */ append<K extends SvgItem>( item: K ): K { this._dom.appendChild( item.getDom() ); return item; } appendItems<K extends SvgItem>( items: K[] ) { items.forEach( item => { this._dom.appendChild( item.getDom() ); } ); } /** * */ path( ): SvgPath { const path = new SvgPath( ); return this.append( path ); } text( x: number, y: number, txt: string ) { const text = new SvgText( x, y, txt ); return this.append( text ); } ellipse( x: number, y: number, r1: number, r2: number ): SvgShape { const shape = new SvgShape( 'ellipse' ); shape.setAttr( 'cx', num(x)+'' ); shape.setAttr( 'cy', num(y)+'' ); shape.setAttr( 'rx', num(r1)+'' ); shape.setAttr( 'ry', num(r2)+'' ); return this.append( shape ); } circle( x: number, y: number, r1: number ): SvgShape { const shape = new SvgShape( 'ellipse' ); shape.setAttr( 'cx', num(x)+'' ); shape.setAttr( 'cy', num(y)+'' ); shape.setAttr( 'rx', num(r1)+'' ); shape.setAttr( 'ry', num(r1)+'' ); return this.append( shape ); } icon( svg: string, x: number, y: number, w: number, h: number ): SvgIcon { const icon = new SvgIcon( svg ); icon.setAttr( 'x', num(x)+'' ); icon.setAttr( 'y', num(y)+'' ); icon.setAttr( 'width', num(w)+'' ); icon.setAttr( 'height', num(h)+'' ); icon.setStyle( 'width', num(w)+'px' ); icon.setStyle( 'height', num(h)+'px' ); return this.append( icon ); } rect( x: number, y: number, w: number, h: number ): SvgShape { if( h<0 ) { y = y+h; h = -h; } const shape = new SvgShape( 'rect' ); shape.setAttr( 'x', num(x)+'' ); shape.setAttr( 'y', num(y)+'' ); shape.setAttr( 'width', num(w)+'' ); shape.setAttr( 'height', num(h)+'' ); return this.append( shape ); } group( id?: string ) { const group = new SvgGroup( ); if( id ) { group.setAttr( 'id', id ); } return this.append( group ); } /** * * example * ```ts * const g = c.linear_gradient( '0%', '0%', '0%', '100%' ) * .addStop( 0, 'red' ) * .addStop( 100, 'green' ); * * p.rect( 0, 0, 100, 100 ) * .stroke( g.id ); * * ``` */ linear_gradient( x1: number_or_perc, y1: number_or_perc, x2: number_or_perc, y2: number_or_perc ) { const grad = new SvgGradient( x1, y1, x2, y2 ); return this.append( grad ); } /** * clear */ clear( ) { const dom = this._dom; while( dom.firstChild ) { dom.removeChild( dom.firstChild ); } } } export class SvgBuilder extends SvgGroup { private static g_clip_id = 1; private static g_pat_id = 1; constructor( ) { super( ); } addClip( x: number, y: number, w: number, h: number ) { const id = 'clip-'+SvgBuilder.g_clip_id++; const clip = new SvgGroup( 'clipPath' ); clip.setAttr('id', id ); clip.rect( x, y, w, h ); this.append(clip); return {id,clip}; } addPattern( x: number, y: number, w: number, h: number ) { const id = 'pat-'+SvgBuilder.g_pat_id++; const pat = new SvgGroup( 'pattern' ); pat.setAttr( 'id', id ); pat.setAttr( 'x', num(x)+'' ); pat.setAttr( 'y', num(y)+'' ); pat.setAttr( 'width', num(w)+'' ); pat.setAttr( 'height', num(h)+'' ); pat.setAttr( 'patternUnits', "userSpaceOnUse" ); this.append(pat); return {id,pat}; } } /** * */ interface SvgProps extends ComponentProps { viewbox?: string; svg: SvgBuilder; } /** * */ export class SvgComponent<P extends SvgProps = SvgProps> extends Component<P> { constructor( props: P ) { super( { ...props, tag: "svg", ns: SVG_NS } ); this.setAttribute( 'xmlns', SVG_NS ); if( props.viewbox ) { this.setAttribute( "viewBox", props.viewbox ); } if( props.svg ) { this.dom.appendChild( props.svg.getDom() ); } } setSvg( bld: SvgBuilder ) { this.clearContent( ); this.dom.appendChild( bld.getDom() ); } addItems( ...items: SvgItem[] ) { items.forEach( item => this.dom.appendChild( item.getDom() ) ); } }