svg-turtle
Version:
a turtle graphics library with SVG output
688 lines (518 loc) • 21.4 kB
text/typescript
//----------------------------------------------------------------------------//
// SVG-Turtle //
//----------------------------------------------------------------------------//
import {
throwError,
ValueIsFiniteNumber, ValueIsNumberInRange, ValueIsPlainObject,
ValueIsOneOf, ValueIsColor,
ValidatorForClassifier, acceptNil, rejectNil,
allowFiniteNumber, expectFiniteNumber, allowOneOf
} from 'javascript-interface-library'
export type TUR_Location = number // mainly for illustrative purposes
export type TUR_Dimension = number // dto.
export type TUR_Angle = number // dto.
export type TUR_Color = string // dto.
export const TUR_Lineatures = ['solid','dotted','dashed']
export type TUR_Lineature = typeof TUR_Lineatures[number]
export const TUR_Joins = ['bevel','miter','round']
export type TUR_Join = typeof TUR_Joins[number]
export const TUR_Caps = ['butt','round','square']
export type TUR_Cap = typeof TUR_Caps[number]
export type TUR_PathOptionSet = {
x?:TUR_Location, y?:TUR_Location, Direction?:TUR_Angle,
Width?:TUR_Dimension, Color?:TUR_Color,
Lineature?:TUR_Lineature, Join?:TUR_Join, Cap?:TUR_Cap
}
export type TUR_Position = {
x:TUR_Location, y:TUR_Location
}
export type TUR_Alignment = {
x:TUR_Location, y:TUR_Location, Direction:TUR_Angle
}
/**** ValueIsPosition ****/
export function ValueIsPosition (Value:any):boolean {
return (
ValueIsPlainObject(Value) &&
ValueIsFiniteNumber(Value.x) &&
ValueIsFiniteNumber(Value.y)
)
}
/**** allow/expect[ed]Position ****/
export const allowPosition = ValidatorForClassifier(
ValueIsPosition, acceptNil, 'turtle position'
), allowedPosition = allowPosition
export const expectPosition = ValidatorForClassifier(
ValueIsPosition, rejectNil, 'turtle position'
), expectedPosition = expectPosition
/**** ValueIsAlignment ****/
export function ValueIsAlignment (Value:any):boolean {
return (
ValueIsPlainObject(Value) &&
ValueIsFiniteNumber(Value.x) &&
ValueIsFiniteNumber(Value.y) &&
ValueIsFiniteNumber(Value.Direction)
)
}
/**** allow/expect[ed]Alignment ****/
export const allowAlignment = ValidatorForClassifier(
ValueIsAlignment, acceptNil, 'turtle alignment'
), allowedAlignment = allowAlignment
export const expectAlignment = ValidatorForClassifier(
ValueIsAlignment, rejectNil, 'turtle alignment'
), expectedAlignment = expectAlignment
/**** ValueIsPathOptionSet ****/
export function ValueIsPathOptionSet (Value:any):boolean {
return (
ValueIsPlainObject(Value) &&
((Value.x == null) || ValueIsFiniteNumber(Value.x)) &&
((Value.y == null) || ValueIsFiniteNumber(Value.y)) &&
((Value.Direction == null) || ValueIsFiniteNumber(Value.Direction)) &&
((Value.Width == null) || ValueIsNumberInRange(Value.Width, 0)) &&
((Value.Color == null) || ValueIsColor(Value.Color)) &&
((Value.Lineature == null) || ValueIsOneOf(Value.Lineature,TUR_Lineatures)) &&
((Value.Join == null) || ValueIsOneOf(Value.Join,TUR_Joins)) &&
((Value.Cap == null) || ValueIsOneOf(Value.Cap,TUR_Caps))
)
}
/**** allow/expect[ed]PathOptionSet ****/
export const allowPathOptionSet = ValidatorForClassifier(
ValueIsPathOptionSet, acceptNil, 'turtle path option set'
), allowedPathOptionSet = allowPathOptionSet
export const expectPathOptionSet = ValidatorForClassifier(
ValueIsPathOptionSet, rejectNil, 'turtle path option set'
), expectedPathOptionSet = expectPathOptionSet
/**** Graphic ****/
export class Graphic {
private SVGContent:string = ''
private currentPath:string|undefined = undefined
private minX:TUR_Location|undefined; private maxX:TUR_Location|undefined
private minY:TUR_Location|undefined; private maxY:TUR_Location|undefined
private currentX:TUR_Location = 0
private currentY:TUR_Location = 0
private currentDirection:TUR_Angle = 0
private currentWidth:TUR_Dimension = 1
private currentColor:TUR_Color = '#000000'
private currentLineature:TUR_Lineature = 'solid'
private currentJoin:TUR_Join = 'round'
private currentCap:TUR_Cap = 'round'
/**** _initialize ****/
private _initialize ():void {
if (this.currentX == null) { this.currentX = 0 }
if (this.currentY == null) { this.currentY = 0 }
if (this.currentDirection == null) { this.currentDirection = 0 }
if (this.currentWidth == null) { this.currentWidth = 1 }
if (this.currentColor == null) { this.currentColor = '#000000' }
if (this.currentLineature == null) { this.currentLineature = 'solid' }
if (this.currentJoin == null) { this.currentJoin = 'round' }
if (this.currentCap == null) { this.currentCap = 'round' }
}
/**** reset ****/
public reset ():Graphic {
this.currentX = 0
this.currentY = 0
this.currentDirection = 0
this.currentWidth = 1
this.currentColor = '#000000'
this.currentLineature = 'solid'
this.currentJoin = 'round'
this.currentCap = 'round'
return this
}
/**** beginPath ****/
public beginPath (PathOptionSet?:TUR_PathOptionSet):Graphic {
allowPathOptionSet('option set',PathOptionSet)
if (this.currentPath != null) {
this.endPath()
}
this._initialize()
if (PathOptionSet != null) {
if (PathOptionSet.x != null) { this.currentX = PathOptionSet.x as TUR_Location }
if (PathOptionSet.y != null) { this.currentY = PathOptionSet.y as TUR_Location }
if (PathOptionSet.Direction != null) { this.currentDirection = PathOptionSet.Direction as TUR_Angle }
if (PathOptionSet.Width != null) { this.currentWidth = PathOptionSet.Width as TUR_Dimension }
if (PathOptionSet.Color != null) { this.currentColor = PathOptionSet.Color as TUR_Color }
if (PathOptionSet.Lineature != null) { this.currentLineature = PathOptionSet.Lineature as TUR_Lineature }
if (PathOptionSet.Join != null) { this.currentJoin = PathOptionSet.Join as TUR_Join }
if (PathOptionSet.Cap != null) { this.currentCap = PathOptionSet.Cap as TUR_Cap }
}
if (this.minX == null) {
this.minX = this.maxX = this.currentX
this.minY = this.maxY = this.currentY
}
this.currentPath = '<path ' +
'fill="none" ' +
'stroke="' + this.currentColor + '" ' +
'stroke-width="' + this.currentWidth + '" ' +
'stroke-linejoin="' + this.currentJoin + '" ' +
'stroke-linecap="' + this.currentCap + '" '
switch (this.currentLineature) {
case 'dotted':
this.currentPath += 'stroke-dasharray="1" '
break
case 'dashed':
this.currentPath += 'stroke-dasharray="3 1" '
break
case 'solid': default:
this.currentPath += 'stroke-dasharray="none" '
}
this.currentPath += 'd="'
this.moveTo(this.currentX,this.currentY)
return this
}
/**** turn ****/
public turn (DirectionChange:TUR_Angle):Graphic {
expectFiniteNumber('direction change',DirectionChange)
this.currentDirection += DirectionChange
return this
}
/**** turnTo ****/
public turnTo (Direction:TUR_Angle):Graphic {
expectFiniteNumber('direction',Direction)
this.currentDirection = Direction
return this
}
/**** turnLeft ****/
public turnLeft (DirectionChange:TUR_Angle):Graphic {
expectFiniteNumber('direction change',DirectionChange)
this.currentDirection -= DirectionChange
return this
}
/**** turnRight ****/
public turnRight (DirectionChange:TUR_Angle):Graphic {
expectFiniteNumber('direction change',DirectionChange)
this.currentDirection += DirectionChange
return this
}
/**** move ****/
public move (Distance:TUR_Location):Graphic {
expectFiniteNumber('distance',Distance)
let DirectionInRadians = this.currentDirection * Math.PI/180
this.moveTo( // DRY approach
(this.currentX || 0) + Distance * Math.cos(DirectionInRadians),
(this.currentY || 0) + Distance * Math.sin(DirectionInRadians)
)
return this
}
/**** moveTo ****/
public moveTo (x:TUR_Location, y:TUR_Location):Graphic {
expectFiniteNumber('x coordinate',x)
expectFiniteNumber('y coordinate',y)
this.currentX = x
this.currentY = y
if (this.currentPath != null) {
this.currentPath += 'M ' + rounded(x) + ',' + rounded(y) + ' '
}
return this
}
/**** draw ****/
public draw (Distance:TUR_Location):Graphic {
expectFiniteNumber('distance',Distance)
let DirectionInRadians = this.currentDirection * Math.PI/180
this.drawTo( // DRY approach
(this.currentX || 0) + Distance * Math.cos(DirectionInRadians),
(this.currentY || 0) + Distance * Math.sin(DirectionInRadians)
)
return this
}
/**** drawTo ****/
public drawTo (x:TUR_Location, y:TUR_Location):Graphic {
expectFiniteNumber('x coordinate',x)
expectFiniteNumber('y coordinate',y)
if (this.currentPath == null) {
this.beginPath()
}
this._updateBoundingBox(
this.currentX-this.currentWidth/2, this.currentX+this.currentWidth/2,
this.currentY-this.currentWidth/2, this.currentY+this.currentWidth/2
)
this.currentX = x
this.currentY = y
this.currentPath += 'L ' + rounded(x) + ',' + rounded(y) + ' '
this._updateBoundingBox(
this.currentX-this.currentWidth/2, this.currentX+this.currentWidth/2,
this.currentY-this.currentWidth/2, this.currentY+this.currentWidth/2
)
return this
}
/**** curveLeft/Right ****/
public curveLeft (
Angle:TUR_Angle, rx:TUR_Dimension, ry?:TUR_Dimension
):Graphic {
return this._curve(Angle, rx,ry, false)
}
public curveRight (
Angle:TUR_Angle, rx:TUR_Dimension, ry?:TUR_Dimension
):Graphic {
return this._curve(Angle, rx,ry, true)
}
/**** _curve ****/
private _curve (
Angle:TUR_Angle, rx:TUR_Dimension, ry:TUR_Dimension|undefined,
clockwise:boolean
):Graphic {
expectFiniteNumber('turn angle',Angle)
expectFiniteNumber ('x radius',rx)
allowFiniteNumber ('y radius',ry)
if (ry == null) { ry = rx }
let absAngle = Math.abs(Angle)
if (absAngle < 1e-6) { return this }
const pi = Math.PI; const sin = Math.sin
const deg2rad = pi/180; const cos = Math.cos
if (this.currentPath == null) {
this.beginPath()
}
/**** fix ellipse starting point ****/
let x0 = this.currentX
let y0 = this.currentY
this._updateBoundingBox(
x0-this.currentWidth/2, x0+this.currentWidth/2,
y0-this.currentWidth/2, y0+this.currentWidth/2
)
/**** compute ellipse center ****/
let Direction = this.currentDirection
let DirectionInRadians = Direction * deg2rad
let NormalInRadians = DirectionInRadians + (clockwise ? pi/2 : -pi/2)
let cx = x0 + ry * cos(NormalInRadians) // "ry" is correct!
let cy = y0 + ry * sin(NormalInRadians) // dto.
/**** compute ellipse end point ****/
let AngleInRadians = (
clockwise ? -pi/2 + Angle * deg2rad : pi/2 - Angle * deg2rad
)
let auxX = rx * cos(AngleInRadians)
let auxY = ry * sin(AngleInRadians)
let x1 = cx + auxX * cos(DirectionInRadians) - auxY * sin(DirectionInRadians)
let y1 = cy + auxX * sin(DirectionInRadians) + auxY * cos(DirectionInRadians)
/**** construct SVG path ****/
let fullEllipse = (absAngle >= 360)
let largeArcFlag = (absAngle >= 180 ? 1 : 0)
let SweepFlag = (clockwise ? (Angle >= 0 ? 1 : 0) : (Angle >= 0 ? 0 : 1))
if (fullEllipse) {
auxX = cx + (cx-x0)
auxY = cy + (cy-y0)
this.currentPath += (
'A ' + rounded(rx) + ' ' + rounded(ry) + ' ' +
rounded(Direction) + ' 1 ' + SweepFlag + ' ' +
rounded(auxX) + ' ' + rounded(auxY) + ' '
) + (
'A ' + rounded(rx) + ' ' + rounded(ry) + ' ' +
rounded(Direction) + ' 1 ' + SweepFlag + ' ' +
rounded(x0) + ' ' + rounded(y0) + ' '
) + 'M ' + rounded(x1) + ' ' + rounded(y1) + ' '
} else {
this.currentPath += (
'A ' + rounded(rx) + ' ' + rounded(ry) + ' ' +
rounded(Direction) + ' ' + largeArcFlag + ' ' + SweepFlag + ' ' +
rounded(x1) + ' ' + rounded(y1) + ' '
)
}
/**** compute ellipse x/y bounds in rotated coordinate system ****/
// see https://math.stackexchange.com/questions/91132/how-to-get-the-limits-of-rotated-ellipse
let xMax = Math.sqrt( // still centered at origin, not cx/cy
rx*rx * Math.pow(cos(DirectionInRadians),2) +
ry*ry * Math.pow(sin(DirectionInRadians),2)
)
let yMax = Math.sqrt( // dto.
rx*rx * Math.pow(sin(DirectionInRadians),2) +
ry*ry * Math.pow(cos(DirectionInRadians),2)
)
for (let i = 0; i < 4; i++) {
let xSign = (i % 2 === 0 ? 1 : -1)
let ySign = (i < 2 ? 1 : -1)
let x = xSign*xMax
let y = ySign*yMax
let PointShouldBeUsed
if (fullEllipse) {
PointShouldBeUsed = true
} else {
/**** rotate extremal points back into ellipse coordinates ****/
let maxX = x * cos(-DirectionInRadians) - y * sin(-DirectionInRadians)
let maxY = x * sin(-DirectionInRadians) + y * cos(-DirectionInRadians)
maxX = maxX / rx
maxY = maxY / ry
/**** compute extremal point angles and check if within arc ****/
let PointAngleInRadians = Math.atan2(maxY,maxX)
let StartAngleInRadians = (clockwise ? -pi/2 : pi/2)
let EndAngleInRadians = AngleInRadians // already computed
if ((StartAngleInRadians < -pi) || (EndAngleInRadians < -pi)) {
StartAngleInRadians += 2*pi // that's sufficient, because...
EndAngleInRadians += 2*pi // ..."fullEllipse" is false here
}
if (StartAngleInRadians > EndAngleInRadians) {
let temp = StartAngleInRadians
StartAngleInRadians = EndAngleInRadians
EndAngleInRadians = temp
}
PointShouldBeUsed = ( // common cases
(StartAngleInRadians <= PointAngleInRadians) &&
(PointAngleInRadians <= EndAngleInRadians)
) || ( // rare cases
(PointAngleInRadians < 0) &&
(StartAngleInRadians <= PointAngleInRadians + 2*pi) &&
(PointAngleInRadians + 2*pi <= EndAngleInRadians)
)
}
if (PointShouldBeUsed) {
this._updateBoundingBox(
cx + x-this.currentWidth/2, cx + x+this.currentWidth/2,
cy + y-this.currentWidth/2, cy + y+this.currentWidth/2
)
}
}
/**** update turtle ****/
this.currentDirection += (Angle >= 0 ? Angle : 180+Angle) * (clockwise ? 1 : -1)
this.currentX = x1
this.currentY = y1
this._updateBoundingBox(
x1-this.currentWidth/2, x1+this.currentWidth/2,
y1-this.currentWidth/2, y1+this.currentWidth/2
)
return this
}
/**** endPath ****/
public endPath ():Graphic {
if (this.currentPath != null) {
this.currentPath += '"/>'
this.SVGContent += this.currentPath
this.currentPath = undefined
}
return this
}
/**** closePath ****/
public closePath ():Graphic {
if (this.currentPath != null) {
this.currentPath += 'Z'
this.endPath()
}
return this
}
/**** currentPosition ****/
public currentPosition ():TUR_Position {
return { x:this.currentX, y:this.currentY }
}
/**** positionAt ****/
public positionAt (Position:TUR_Position):Graphic {
allowPosition('turtle position',Position)
if (this.currentPath == null) {
this.currentX = Position.x
this.currentY = Position.y
} else {
this.moveTo(Position.x,Position.y)
}
return this
}
/**** currentAlignment ****/
public currentAlignment ():TUR_Alignment {
return {
x:this.currentX, y:this.currentY, Direction:this.currentDirection
}
}
/**** alignAt ****/
public alignAt (Alignment:TUR_Alignment):Graphic {
allowAlignment('turtle alignment',Alignment)
this.currentDirection = Alignment.Direction
if (this.currentPath == null) {
this.currentX = Alignment.x
this.currentY = Alignment.y
} else {
this.moveTo(Alignment.x,Alignment.y)
}
return this
}
/**** Limits ****/
public Limits ():{
xMin:number, yMin:number, xMax:number,yMax:number
} {
return {
xMin:this.minX || 0, yMin:this.minY || 0,
xMax:this.maxX || 0, yMax:this.maxY || 0
}
}
/**** asSVG ****/
public asSVG (
Unit?:'px'|'mm'|'cm'|'in',
xMin?:number,yMin?:number, xMax?:number,yMax?:number
):string {
allowOneOf('SVG unit',Unit, ['px','mm','cm','in'])
allowFiniteNumber('minimal x',xMin)
allowFiniteNumber('maximal x',xMax)
allowFiniteNumber('minimal y',yMin)
allowFiniteNumber('maximal y',yMax)
if (this.minX == null) { // very special case: nothing has been drawn yet
this.minX = this.maxX = this.minY = this.maxY = 0
}
if (Unit == null) { Unit = 'px' }
if (xMin == null) { xMin = this.minX }
if (xMax == null) { xMax = this.maxX }
if (yMin == null) { yMin = this.minY }
if (yMax == null) { yMax = this.maxY }
// @ts-ignore TS2532 we know that xMax and xMin are defined
let Width = xMax-xMin
// @ts-ignore TS2532 we know that yMax and yMin are defined
let Height = yMax-yMin
if (Width < 0) throwError('InvalidArgument: invalid x range given')
if (Height < 0) throwError('InvalidArgument: invalid y range given')
if (this.currentPath != null) { // if need be: end an ongoing path
this.endPath()
}
return (
'<svg xmlns="http://www.w3.org/2000/svg" ' +
'width="' + rounded(Width) + Unit + '" ' +
'height="' + rounded(Height) + Unit + '" ' +
// @ts-ignore TS2532 we know that xMin and yMin are defined
'viewBox="' + floored(xMin) + ' ' + floored(yMin) + ' ' +
ceiled(Width) + ' ' + ceiled(Height) + '" ' +
'vector-effect="non-scaling-stroke"' +
'>' +
this.SVGContent +
'</svg>'
)
}
/**** asSVGwith72dpi ****/
public asSVGwith72dpi (
Unit?:'px'|'mm'|'cm'|'in',
xMin?:number,yMin?:number, xMax?:number,yMax?:number
):string {
let SVG = this.asSVG(Unit, xMin,yMin, xMax,yMax) // also validates arg.s
let Scale = 72 / {
'px':25.4, 'mm':25.4, 'cm':2.54, 'in':1
}[Unit || 'mm']
if (xMin == null) { xMin = this.minX }
if (xMax == null) { xMax = this.maxX }
if (yMin == null) { yMin = this.minY }
if (yMax == null) { yMax = this.maxY }
return (
'<svg xmlns="http://www.w3.org/2000/svg" ' +
// @ts-ignore TS2532 we know that xMin and yMin are defined
'viewBox="' + floored(Scale*xMin) + ' ' + floored(Scale*yMin) + ' ' +
// @ts-ignore TS2532 we know that xMin,xMax,yMin and yMax are defined
ceiled(Scale*(xMax-xMin)) + ' ' + ceiled(Scale*(yMax-yMin)) + '" ' +
'vector-effect="non-scaling-stroke"' +
'>' +
'<g transform="scale(' + Scale + ',' + Scale + ')">' +
SVG +
'</g></svg>'
)
}
/**** _updateBoundingBox ****/
private _updateBoundingBox (
minX:TUR_Location, maxX:TUR_Location,
minY:TUR_Location, maxY:TUR_Location
):void {
this.minX = Math.min(this.minX as TUR_Location,minX)
this.maxX = Math.max(this.maxX as TUR_Location,maxX)
this.minY = Math.min(this.minY as TUR_Location,minY)
this.maxY = Math.max(this.maxY as TUR_Location,maxY)
}
}
/**** rounded ****/
function rounded (Value:number):number {
return Math.round(Value*100)/100
}
/**** ceiled ****/
function ceiled (Value:number):number {
return Math.ceil(Value*100)/100
}
/**** floored ****/
function floored (Value:number):number {
return Math.floor(Value*100)/100
}