From 9893bb4dca454032ca874a19b5dce6ba1ca3efce Mon Sep 17 00:00:00 2001 From: graphieros Date: Sun, 21 Jan 2024 16:53:23 +0100 Subject: [PATCH] Added arrow element --- playground/src/main.ts | 20 +++++- savyg/package.json | 2 +- savyg/src/utils_common.ts | 58 ---------------- savyg/src/utils_svg.ts | 113 +++++++++++++++++++++++++++++++ savyg/src/utils_svg_types.ts | 41 ++++++++++- savyg/tests/utils_common.test.ts | 93 +------------------------ savyg/tests/utils_svg.test.ts | 39 ++++++++++- 7 files changed, 212 insertions(+), 154 deletions(-) diff --git a/playground/src/main.ts b/playground/src/main.ts index 7170411..11d1db7 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -1,4 +1,4 @@ -import { chartXy, radialGradient, svg, circle, chartDonut, chartGauge, clipPath, path, use, findArcMidpoint } from "savyg"; +import { chartXy, arrow, chartDonut, chartGauge, clipPath, path, use, findArcMidpoint } from "savyg"; // const parent = document.getElementById("svg") as HTMLElement const div = document.getElementById("div") as HTMLElement @@ -35,6 +35,7 @@ chartGauge({ }, parent: div }) + let gauge = chartGauge({ dataset: { value: 4.56, @@ -59,6 +60,23 @@ let gauge = chartGauge({ parent: div }) +arrow({ + options: { + x1: 10, + y1: 10, + x2: 60, + y2: 100, + marker: "both", + stroke: "black", + "stroke-linecap": "round", + "stroke-width": 1, + size: 12 + }, + parent: gauge.chart +}) + + + let xy = chartXy({ dataset: [ { diff --git a/savyg/package.json b/savyg/package.json index b196c63..af0f6bb 100644 --- a/savyg/package.json +++ b/savyg/package.json @@ -1,7 +1,7 @@ { "name": "savyg", "private": false, - "version": "1.1.6", + "version": "1.1.7", "description": "A savvy library to create svg elements and charts with ease", "author": "Alec Lloyd Probert", "repository": { diff --git a/savyg/src/utils_common.ts b/savyg/src/utils_common.ts index c522c1e..ab72e1d 100644 --- a/savyg/src/utils_common.ts +++ b/savyg/src/utils_common.ts @@ -1,6 +1,4 @@ import { BaseDatasetItem } from "./utils_chart_xy"; -import { DrawingArea } from "./utils_svg_types"; - export function getSvgDimensions(viewBox: string) { const dimensions = viewBox.split(' '); @@ -249,60 +247,6 @@ export function makeDonut({ return ratios; } -export function createDonutMarker({ drawingArea, element, offset }: { drawingArea: DrawingArea, element: any, offset: number }) { - const dx = drawingArea.centerX - element.center.endX; - const dy = drawingArea.centerY - element.center.endY; - - const length = Math.sqrt(dx * dx + dy * dy); - const endX = element.center.endX + (dx / length) * (length - offset); - const endY = element.center.endY + (dy / length) * (length - offset); - - return { - x1: element.center.endX, - y1: element.center.endY, - x2: endX, - y2: endY - } -} - -export function positionDonutLabel({ drawingArea, element, offset = 0 }: { drawingArea: DrawingArea, element: any, offset?: number }) { - let position = { - x: element.center.endX, - y: element.center.endY + offset, - textAnchor: "middle" - }; - - if (element.center.endX - 12 > drawingArea.centerX) { - position.textAnchor = "start"; - position.x += 12; - } - - if (element.center.endX + 12 < drawingArea.centerX) { - position.textAnchor = "end"; - position.x -= 12; - } - - if (element.center.endX === drawingArea.centerX) { - position.textAnchor = "middle"; - if (element.center.endY > drawingArea.centerY) { - position.y += 12; - } - if (element.center.endY < drawingArea.centerY) { - position.y -= 12; - } - } - - if (element.center.endY - 6 < (drawingArea.top + drawingArea.height / 4)) { - position.y = createDonutMarker({ drawingArea, element, offset: drawingArea.width / 3 }).y2 + offset; - } - - if (element.center.endY + 6 > drawingArea.height - (drawingArea.height / 4)) { - position.y = createDonutMarker({ drawingArea, element, offset: drawingArea.width / 3 }).y2 + offset; - } - - return position; -} - export function fordinum(n: number, r: number = 0, s: string = '', p: string = ''): string { if (isNaN(n)) return n as unknown as string return p + (Number(n).toFixed(r)).toLocaleString() + s @@ -310,7 +254,6 @@ export function fordinum(n: number, r: number = 0, s: string = '', p: string = ' const utils_commons = { calculateNiceScale, - createDonutMarker, createUid, fordinum, getClosestDecimal, @@ -318,7 +261,6 @@ const utils_commons = { getMinMaxInDatasetItems, getSvgDimensions, makeDonut, - positionDonutLabel, ratioToMax, } diff --git a/savyg/src/utils_svg.ts b/savyg/src/utils_svg.ts index 35196db..364928f 100644 --- a/savyg/src/utils_svg.ts +++ b/savyg/src/utils_svg.ts @@ -1,5 +1,6 @@ import { Coordinates, GradientStop, Shape, SvgItem, SvgOptions } from "./utils_svg_types" import { CONSTANT } from "./constants" +import { createUid } from "./utils_common"; /** * @@ -171,6 +172,117 @@ export function text(attrs: { }) } +/** + * + * @description Creates a svg marker element. + * @returns a marker svg element + */ +export function marker(attrs: { + options: SvgOptions[SvgItem.MARKER], + parent?: SVGElement | HTMLElement, +}) { + return element({ + el: SvgItem.MARKER, + options: attrs.options, + parent: attrs.parent + }) +} + +export function arrow(attrs: { + options: SvgOptions[SvgItem.ARROW], + parent?: SVGElement | HTMLElement +}) { + const uid = createUid(); + + const g = element({ + el: SvgItem.G, + options: { + className: "savyg-arrow" + }, + }) + + const defs = element({ + el: SvgItem.DEFS, + options: {}, + parent: g + }) + const baseSize = attrs.options.size ?? 10; + const viewBox = `0 0 ${baseSize} ${baseSize}` + const refX = baseSize / 2 + const refY = baseSize / 2 + const markerWidth = refX * 1.2 + const markerHeight = refY * 1.2 + + if (['end', 'both'].includes(attrs.options.marker)) { + const markerEnd = marker({ + options: { + id: `marker-end-${uid}`, + orient: "auto", + viewBox, + refX, + refY, + markerHeight: attrs.options.markerHeight ?? markerHeight, + markerWidth: attrs.options.markerWidth ?? markerWidth + }, + parent: defs + }) + + path({ + options: { + d: `M 0 0 L ${baseSize} ${refX} L 0 ${baseSize} z`, + fill: attrs.options.stroke + }, + parent: markerEnd + }) + } + if (['start', 'both'].includes(attrs.options.marker)) { + const markerStart = marker({ + options: { + id: `marker-start-${uid}`, + orient: "auto-start-reverse", + viewBox, + refX, + refY, + markerHeight: attrs.options.markerHeight ?? markerHeight, + markerWidth: attrs.options.markerWidth ?? markerWidth + }, + parent: defs + }) + path({ + options: { + d: `M 0 0 L ${baseSize} ${refX} L 0 ${baseSize} z`, + fill: attrs.options.stroke + }, + parent: markerStart + }) + } + + line({ + options: { + x1: attrs.options.x1, + y1: attrs.options.y1, + x2: attrs.options.x2, + y2: attrs.options.y2, + stroke: attrs.options.stroke, + "stroke-width": attrs.options["stroke-width"], + "stroke-dasharray": attrs.options["stroke-dasharray"], + "stroke-dashoffset": attrs.options["stroke-dashoffset"], + "stroke-linecap": attrs.options["stroke-linecap"], + "stroke-linejoin": attrs.options["stroke-linejoin"], + "shape-rendering": attrs.options["shape-rendering"], + "marker-end": ['end', 'both'].includes(attrs.options.marker) ? `url(#marker-end-${uid})` : '', + "marker-start": ['start', 'both'].includes(attrs.options.marker) ? `url(#marker-start-${uid})` : '', + }, + parent: g + }) + + if (attrs.parent) { + attrs.parent.appendChild(g) + } + + return g +} + export function calcPolygonPoints({ centerX, centerY, @@ -408,6 +520,7 @@ const utils_svg = { freePolygon, line, linearGradient, + marker, offsetFromCenterPoint, path, radialGradient, diff --git a/savyg/src/utils_svg_types.ts b/savyg/src/utils_svg_types.ts index fee6877..cee852b 100644 --- a/savyg/src/utils_svg_types.ts +++ b/savyg/src/utils_svg_types.ts @@ -14,6 +14,8 @@ export enum SvgItem { TEXT = "text", CLIP_PATH = "clipPath", USE = "use", + MARKER = "marker", + ARROW = "arrow" } export type ShapeRendering = "auto" | "optimizeSpeed" | "crispEdges" | "geometricPrecision" @@ -55,7 +57,7 @@ export type DrawingArea = { centerY: number; } -export type Shape = "circle" | "defs" | "g" | "line" | "linearGradient" | "radialGradient" | "path" | "polygon" | "rect" | "stop" | "svg" | "foreignObject" | "text" | "clipPath" | "use" +export type Shape = "circle" | "defs" | "g" | "line" | "linearGradient" | "radialGradient" | "path" | "polygon" | "rect" | "stop" | "svg" | "foreignObject" | "text" | "clipPath" | "use" | "marker" export type StrokeLinecap = "round" | "butt" | "square" export type StrokeLinejoin = "arcs" | "bevel" | "miter" | "miter-clip" | "round" @@ -84,6 +86,8 @@ export type SvgOptions = { [SvgItem.TEXT]: Text [SvgItem.USE]: Use [SvgItem.CLIP_PATH]: ClipPath + [SvgItem.MARKER]: Marker + [SvgItem.ARROW]: Arrow }; @@ -112,6 +116,8 @@ export type Path = StrokeOptions & CommonOptions & { id?: string fill?: string "shape-rendering"?: ShapeRendering + "marker-start"?: string + "marker-end"?: string } export type GradientStop = { @@ -190,6 +196,8 @@ export type Line = StrokeOptions & CommonOptions & { y1: number y2: number "shape-rendering"?: ShapeRendering + "marker-start"?: string + "marker-end"?: string } export type ChartArea = { @@ -199,4 +207,35 @@ export type ChartArea = { bottom: number centerX?: number centerY?: number +} + +export type PreserveAspectRatioValue = + | 'none' + | 'xMinYMin' + | 'xMidYMin' + | 'xMaxYMin' + | 'xMinYMid' + | 'xMidYMid' + | 'xMaxYMid' + | 'xMinYMax' + | 'xMidYMax' + | 'xMaxYMax' + +type PreserveAspectRatio = `${PreserveAspectRatioValue} ${'meet' | 'slice' | ''}`; + +export type Marker = CommonOptions & { + id?: string + markerHeight?: number + markerWidth?: number + markerUnits?: 'userSpaceOnUse' | 'strokeWidth' + orient?: 'auto' | 'auto-start-reverse' + preserveAspectRatio?: PreserveAspectRatio + refX?: number | 'left' | 'center' | 'right' + refY?: number | 'top' | 'center' | 'bottom' + viewBox?: string +} + +export type Arrow = Line & Marker & { + marker: "start" | "end" | "both", + size?: number } \ No newline at end of file diff --git a/savyg/tests/utils_common.test.ts b/savyg/tests/utils_common.test.ts index 46cc6b3..396f1a2 100644 --- a/savyg/tests/utils_common.test.ts +++ b/savyg/tests/utils_common.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest" -import { getSvgDimensions, getMinMaxInDatasetItems, getMaxSerieLength, getClosestDecimal, calculateNiceScale, ratioToMax, positionDonutLabel, createDonutMarker, fordinum } from "../src/utils_common" +import { getSvgDimensions, getMinMaxInDatasetItems, getMaxSerieLength, getClosestDecimal, calculateNiceScale, ratioToMax, fordinum } from "../src/utils_common" describe('getSvgDimensions', () => { test('parses dimensions of a string viewBox', () => { @@ -94,97 +94,6 @@ describe('ratioToMax', () => { }) }) -describe('positionDonutLabel', () => { - test('returns an ideal label position', () => { - const labelLeft = { - drawingArea: { - top: 0, - left: 0, - bottom: 100, - right: 100, - height: 100, - width: 100, - fullHeight: 100, - fullWidth: 100, - centerX: 50, - centerY: 50 - }, - element: { - center: { - endX: 30, - endY: 30, - } - } - } - expect(positionDonutLabel(labelLeft)).toStrictEqual({ - x: 18, - y: 26.429773960448415, - textAnchor: "end" - }) - const labelMiddle = { - drawingArea: { ...labelLeft.drawingArea }, - element: { - center: { - endX: 50, - endY: 20 - } - } - - } - expect(positionDonutLabel(labelMiddle)).toStrictEqual({ - x: 50, - y: 16.666666666666664, - textAnchor: "middle" - }) - const labelRight = { - drawingArea: { ...labelLeft.drawingArea }, - element: { - center: { - endX: 80, - endY: 70 - } - } - } - expect(positionDonutLabel(labelRight)).toStrictEqual({ - x: 92, - y: 68.49000654084097, - textAnchor: "start" - }) - }) -}) - -describe('createDonutMarker', () => { - test('returns marker coordinates', () => { - const label = { - drawingArea: { - top: 0, - left: 0, - bottom: 100, - right: 100, - height: 100, - width: 100, - fullHeight: 100, - fullWidth: 100, - centerX: 50, - centerY: 50 - }, - element: { - center: { - endX: 30, - endY: 30, - } - }, - offset: 0 - } - expect(createDonutMarker(label)).toStrictEqual({ - x1: 30, - x2: 50, - y1: 30, - y2: 50 - }) - }) -}) - describe('fordinum', () => { test('returns a formatted number to rounded text format', () => { expect(fordinum(1)).toBe("1") diff --git a/savyg/tests/utils_svg.test.ts b/savyg/tests/utils_svg.test.ts index 9b62b1a..b9ec20c 100644 --- a/savyg/tests/utils_svg.test.ts +++ b/savyg/tests/utils_svg.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { describe, expect, test } from "vitest" -import { circle, freePolygon, line, path, rect, svg, text, linearGradient, radialGradient } from "../src/utils_svg" +import { circle, freePolygon, line, path, rect, svg, text, linearGradient, radialGradient, offsetFromCenterPoint, setTextAnchorFromCenterPoint } from "../src/utils_svg" describe('circle', () => { test("returns a circle svg element", () => { @@ -257,4 +257,41 @@ describe('radialGradient', () => { }) expect(rg.toString()).toEqual(``) }) +}) + +describe('offsetFromCenterPoint', () => { + test('returns offset cooridnates from a center point', () => { + expect(offsetFromCenterPoint({ + initX: 10, + initY: 10, + offset: 10, + centerX: 10, + centerY: 20, + })).toStrictEqual({ + x: 10, + y: 0 + }) + }) +}) + +describe('setTextAnchorFromCenterPoint', () => { + test('returns a text anchor definition based on offset from centerX', () => { + expect(setTextAnchorFromCenterPoint({ + x: 10, + centerX: 20, + middleRange: 0 + })).toBe("end") + + expect(setTextAnchorFromCenterPoint({ + x: 30, + centerX: 20, + middleRange: 0 + })).toBe("start") + + expect(setTextAnchorFromCenterPoint({ + x: 21, + centerX: 20, + middleRange: 5 + })).toBe("middle") + }) }) \ No newline at end of file