From e53b1e659e8af0ee724801c0ee89cd74dc83d40d Mon Sep 17 00:00:00 2001 From: gtktsc Date: Tue, 19 Sep 2023 22:21:33 +0200 Subject: [PATCH] ascii-57: add simple cli --- .eslintrc.js | 1 + package.json | 9 +- src/cli.ts | 137 ++++++ src/services/__tests__/defaults.test.ts | 157 +++++++ src/services/__tests__/draw.test.ts | 201 +++++++++ src/services/__tests__/overrides.test.ts | 228 ++++++++++ src/services/defaults.ts | 125 ++++++ src/services/draw.ts | 279 ++++++++++++ src/services/overrides.ts | 329 ++++++++++++++ src/services/plot.ts | 536 +++++------------------ src/types/index.ts | 35 +- yarn.lock | 24 +- 12 files changed, 1624 insertions(+), 437 deletions(-) create mode 100755 src/cli.ts create mode 100644 src/services/__tests__/defaults.test.ts create mode 100644 src/services/__tests__/draw.test.ts create mode 100644 src/services/__tests__/overrides.test.ts create mode 100644 src/services/defaults.ts create mode 100644 src/services/draw.ts create mode 100644 src/services/overrides.ts diff --git a/.eslintrc.js b/.eslintrc.js index 7cc1c1d..4abf93d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,5 +18,6 @@ module.exports = { plugins: ['@typescript-eslint'], rules: { 'import/extensions': 'off', + 'no-param-reassign': 'off', }, }; diff --git a/package.json b/package.json index 4f6d256..27bf3b4 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Simple ascii chart generator", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "simple-ascii-chart": "dist/cli.js" + }, "scripts": { "start": "tsc-watch -p tsconfig.json --preserveWatchOutput -w --onSuccess 'node ./dist/index.js'", "lint": "eslint . --ext .ts,.js", @@ -11,6 +14,7 @@ "test": "jest --coverage", "test:watch": "jest --watch", "build": "tsc -p tsconfig.build.json", + "build:watch": "tsc -p tsconfig.build.json -w", "prepare": "husky install", "prepublishOnly": "npm test && npm run lint", "preversion": "npm run lint", @@ -57,5 +61,8 @@ "homepage": "https://github.com/gtktsc/ascii-chart#readme", "files": [ "dist/**/*" - ] + ], + "dependencies": { + "yargs": "^17.7.2" + } } diff --git a/src/cli.ts b/src/cli.ts new file mode 100755 index 0000000..066dfad --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +import * as yargs from 'yargs'; +import plot from './index'; +import { MultiLine, Settings } from './types'; + +const { argv } = yargs + .option('input', { + alias: 'i', + type: 'string', + demandOption: true, + }) + .option('options', { + alias: 'o', + type: 'string', + description: 'plot settings', + }) + .option('height', { + alias: 'h', + type: 'number', + description: 'plot height', + }) + .option('hideXAxis', { + type: 'boolean', + description: 'hide x axis', + }) + .option('hideYAxis', { + type: 'boolean', + description: 'hide Y axis', + }) + .option('fillArea', { + type: 'boolean', + description: 'fill plot area', + }) + .option('width', { + alias: 'w', + type: 'number', + description: 'plot width', + }) + .option('title', { + alias: 't', + type: 'string', + description: 'plot title', + }) + .option('xLabel', { + type: 'string', + description: 'x axis label', + }) + .option('color', { + alias: 'c', + type: 'array', + description: 'plot colors', + }) + .option('axisCenter', { + type: 'array', + description: 'plot center coordinates', + }) + .option('yLabel', { + type: 'string', + description: 'y axis label', + }); + +const withError = (cb: () => unknown) => { + try { + cb(); + } catch (error) { + process.stderr.write('Oops! Something went wrong!\n'); + process.exit(1); + } +}; + +const execute = ({ input, options }: { input: MultiLine; options?: Settings }) => { + withError(() => { + const output = plot(input, options); + process.stdout.write(output); + process.exit(0); + }); +}; + +const prepareParams = ({ + input, + options, + width, + height, + hideYAxis, + hideXAxis, + fillArea, + title, + xLabel, + yLabel, + color, + axisCenter, +}: { + input: string; + options?: string; + title?: string; + xLabel?: string; + yLabel?: string; + width?: number; + height?: number; + fillArea?: boolean; + hideYAxis?: boolean; + hideXAxis?: boolean; + color?: (string | number)[]; + axisCenter?: (string | number)[]; +}) => { + const currentOptions = options ? JSON.parse(options) : {}; + + return { + input: JSON.parse(input) as MultiLine, + options: { + ...currentOptions, + width, + height, + hideYAxis, + hideXAxis, + title, + xLabel, + yLabel, + fillArea, + color, + axisCenter, + }, + }; +}; + +if (argv instanceof Promise) { + argv.then((parameters) => { + withError(() => { + execute(prepareParams(parameters)); + }); + }); +} else { + withError(() => { + execute(prepareParams(argv)); + }); +} diff --git a/src/services/__tests__/defaults.test.ts b/src/services/__tests__/defaults.test.ts new file mode 100644 index 0000000..e37b1e4 --- /dev/null +++ b/src/services/__tests__/defaults.test.ts @@ -0,0 +1,157 @@ +import { getSymbols, getLabelShift, getInput, getChartSize } from '../defaults'; +import { AXIS, EMPTY } from '../../constants'; +import { Coordinates, MultiLine } from '../../types'; + +describe('Chart Helper Functions', () => { + describe('getSymbols', () => { + it('should return default symbols when none are provided', () => { + const symbols = getSymbols({}); + expect(symbols).toEqual({ + axisSymbols: AXIS, + emptySymbol: EMPTY, + backgroundSymbol: EMPTY, + borderSymbol: undefined, + }); + }); + + it('should override default symbols when provided', () => { + const customSymbols = { + axis: { x: 'X', y: 'Y' }, + empty: '-', + background: '=', + border: '#', + }; + const symbols = getSymbols({ symbols: customSymbols }); + expect(symbols).toEqual({ + axisSymbols: { ...AXIS, ...customSymbols.axis }, + emptySymbol: customSymbols.empty, + backgroundSymbol: customSymbols.background, + borderSymbol: customSymbols.border, + }); + }); + }); + + describe('getChartSize', () => { + it('should return default sizes when width and height are not provided', () => { + const input: MultiLine = [ + [ + [1, 2], + [2, 4], + [3, 6], + ], + ]; + const size = getChartSize({ input }); + expect(size).toEqual({ + minX: 1, + plotWidth: 3, // length of rangeX + plotHeight: 5, // maxY - minY + 1 + expansionX: [1, 3], + expansionY: [2, 6], + }); + }); + + it('should use provided width and height', () => { + const input: MultiLine = [ + [ + [1, 2], + [2, 4], + [3, 6], + ], + ]; + + const size = getChartSize({ input, width: 10, height: 10 }); + expect(size).toEqual({ + minX: 1, + plotWidth: 10, + plotHeight: 10, + expansionX: [1, 3], + expansionY: [2, 6], + }); + }); + + it('should adjust for small values without height', () => { + const input: MultiLine = [ + [ + [1, 2], + [2, 4], + ], + ]; + const size = getChartSize({ input }); + expect(size).toEqual({ + minX: 1, + plotWidth: 2, // length of rangeX + plotHeight: 3, // length of rangeY since it's less than 3 without provided height + expansionX: [1, 2], + expansionY: [2, 4], + }); + }); + + it('should handle a mix of positive and negative values', () => { + const input: MultiLine = [ + [ + [-3, -2], + [-2, 4], + [0, 0], + [3, -1], + ], + ]; + const size = getChartSize({ input }); + expect(size).toEqual({ + minX: -3, + plotWidth: 4, // length of rangeX + plotHeight: 7, // maxY - minY + 1 + expansionX: [-3, 3], + expansionY: [-2, 4], + }); + }); + }); + + describe('getLabelShift', () => { + it('should calculate label shifts correctly', () => { + const input: MultiLine = [ + [ + [1, 2], + [3, 4], + [5, 6], + ], + ]; + const transformLabel = (value: number) => value.toString(); + const result = getLabelShift({ + input, + transformLabel, + expansionX: [1, 5], + expansionY: [2, 6], + minX: 1, + }); + + expect(result.xShift).toBe(1); + expect(result.yShift).toBe(1); + }); + }); + + describe('getInput', () => { + it('should convert singleline input to multiline', () => { + const input: Coordinates = [ + [1, 2], + [3, 4], + ]; + const result = getInput({ rawInput: input }); + expect(result).toEqual([input]); + }); + + it('should keep multiline input unchanged', () => { + const input: MultiLine = [ + [ + [1, 2], + [3, 4], + ], + [ + [5, 6], + [7, 8], + ], + ]; + const result = getInput({ rawInput: input }); + expect(result).toEqual(input); + }); + }); +}); diff --git a/src/services/__tests__/draw.test.ts b/src/services/__tests__/draw.test.ts new file mode 100644 index 0000000..692d839 --- /dev/null +++ b/src/services/__tests__/draw.test.ts @@ -0,0 +1,201 @@ +import { + drawXAxisEnd, + drawYAxisEnd, + drawAxis, + drawGraph, + drawChart, + drawCustomLine, + drawLine, + drawShift, +} from '../draw'; +import { AXIS, CHART } from '../../constants'; +import { MultiLine, Point } from '../../types'; + +describe('Drawing functions', () => { + describe('drawXAxisEnd', () => { + it('should draw the X-axis end correctly', () => { + const graph = [ + [' ', ' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' ', ' '], + ]; + const args = { + hasPlaceToRender: true, + yPos: 1, + graph, + yShift: 1, + i: 0, + scaledX: 1, + shift: 0, + signShift: 0, + axisSymbols: AXIS, + pointXShift: ['1'], + }; + drawXAxisEnd(args); + expect(graph[0][4]).toEqual('1'); + expect(graph[1][4]).toEqual(AXIS.x); + }); + it('should draw Y axis end', () => { + const graph = [ + [' ', ' ', ' '], + [' ', ' ', ' '], + [' ', ' ', ' '], + ]; + const params = { + graph, + scaledY: 1, + yShift: 0, + axis: { x: 0, y: 0 }, + pointY: 1, + transformLabel: (value: number) => value.toString(), + axisSymbols: { y: 'Y' }, + expansionX: [0], + expansionY: [0, 1, 2], + }; + + drawYAxisEnd(params); + expect(graph[2][1]).toBe('Y'); + }); + }); + + describe('drawYAxisEnd', () => { + it('should draw the Y-axis end correctly', () => { + const graph = [ + [' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' '], + ]; + const args = { + graph, + scaledY: 1, + yShift: 1, + axis: { x: 1, y: 1 }, + pointY: 2, + transformLabel: (value: number) => value.toString(), + axisSymbols: AXIS, + expansionX: [], + expansionY: [], + }; + drawYAxisEnd(args); + expect(graph[2][3]).toEqual(AXIS.y); + expect(graph[2][2]).toEqual('2'); + }); + }); + + describe('drawAxis', () => { + it('should draw the main axis', () => { + const graph = [ + [' ', ' ', ' '], + [' ', ' ', ' '], + [' ', ' ', ' '], + ]; + const args = { + graph, + axis: { x: 1, y: 1 }, + axisSymbols: AXIS, + }; + drawAxis(args); + expect(graph[0][1]).toEqual(AXIS.n); + expect(graph[1][1]).toEqual(AXIS.ns); + expect(graph[2][1]).toEqual(AXIS.nse); + }); + }); + + describe('drawGraph', () => { + it('should draw an empty graph correctly', () => { + const result = drawGraph({ + plotWidth: 3, + plotHeight: 2, + emptySymbol: ' ', + }); + expect(result).toEqual([ + [' ', ' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' ', ' '], + ]); + }); + }); + + describe('drawChart', () => { + it('should return the graph as a string', () => { + const graph = [ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ['g', 'h', 'i'], + ]; + const result = drawChart({ graph }); + expect(result).toEqual('\nabc\ndef\nghi\n'); + }); + }); + + describe('drawCustomLine', () => { + it('should draw a custom line', () => { + const graph = [ + [' ', ' ', ' '], + [' ', ' ', ' '], + [' ', ' ', ' '], + ]; + const args = { + sortedCoords: [[1, 1]] as Point[], + scaledX: 1, + scaledY: 1, + input: [[1, 1]] as unknown as MultiLine, + index: 0, + lineFormatter: () => ({ x: 1, y: 1, symbol: 'X' }), + graph, + }; + drawCustomLine(args); + expect(graph[1][1]).toEqual('X'); + }); + }); + + describe('drawLine', () => { + it('should draw a line', () => { + const graph = [ + [' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' '], + [' ', ' ', ' ', ' '], + ]; + const args = { + index: 1, + arr: [ + [0, 0], + [1, 1], + ] as Point[], + graph, + scaledX: 1, + scaledY: 1, + plotHeight: 3, + emptySymbol: ' ', + chartSymbols: CHART, + }; + drawLine(args); + expect(graph[3][1]).toEqual('┛'); + expect(graph[2][2]).toEqual('━'); + expect(graph[3][2]).toEqual(' '); + }); + }); + + describe('drawShift', () => { + it('should shift the graph', () => { + const graph = [ + [' ', ' ', ' '], + [' ', ' ', ' '], + ]; + const result = drawShift({ + graph, + plotWidth: 2, + emptySymbol: ' ', + scaledCoords: [ + [0, 0], + [1, 1], + ], + xShift: 1, + yShift: 1, + }); + expect(result.hasToBeMoved).toBe(false); + }); + }); +}); diff --git a/src/services/__tests__/overrides.test.ts b/src/services/__tests__/overrides.test.ts new file mode 100644 index 0000000..a68df0d --- /dev/null +++ b/src/services/__tests__/overrides.test.ts @@ -0,0 +1,228 @@ +import { + setTitle, + addXLable, + addYLabel, + addLegend, + addBorder, + addBackgroundSymbol, + addThresholds, + setFillArea, + removeEmptyLines, + getTransformLabel, +} from '../overrides'; +import { CHART, EMPTY } from '../../constants'; +import { Formatter, FormatterHelpers, Graph, Legend, Threshold } from '../../types'; + +describe('Graph Utility Functions', () => { + let graph: Graph = []; + let defaultGraph: Graph = []; + const backgroundSymbol = EMPTY; + const plotWidth = 10; + const yShift = 2; + + beforeEach(() => { + defaultGraph = Array(8).fill([...Array(plotWidth).fill(backgroundSymbol)]); // Adjusted size to 8 rows + graph = Array(8).fill([...Array(plotWidth).fill(backgroundSymbol)]); // Adjusted size to 8 rows + }); + + describe('setTitle', () => { + it('should set the title correctly', () => { + const title = 'TestTitle'; + + setTitle({ title, graph, backgroundSymbol, plotWidth, yShift }); + + expect(graph[0].join('')).toContain(title); + }); + }); + + describe('addXLable', () => { + it('should add the xLabel correctly', () => { + const xLabel = 'XLabel'; + + addXLable({ xLabel, graph, backgroundSymbol, plotWidth, yShift }); + + expect(graph[graph.length - 1].join('')).toContain(xLabel); + }); + }); + describe('addYLabel', () => { + it('should add the yLabel correctly', () => { + const yLabel = 'YLabel'; + addYLabel({ yLabel, graph, backgroundSymbol }); + + expect(graph[0].reverse().join('')).toContain(yLabel); + }); + }); + + describe('addLegend', () => { + it('should add the legend correctly', () => { + const legend = { position: 'top', series: ['A', 'B'] } as Legend; + + addLegend({ legend, graph, backgroundSymbol }); + + expect(graph[0].join('')).toContain('A'); + expect(graph[1].join('')).toContain('B'); + }); + }); + + describe('addBorder', () => { + it('should add the border correctly', () => { + const borderSymbol = '#'; + + addBorder({ graph, borderSymbol }); + + expect(graph[0][0]).toBe(borderSymbol); + expect(graph[0][graph[0].length - 1]).toBe(borderSymbol); + expect(graph[graph.length - 1][0]).toBe(borderSymbol); + expect(graph[graph.length - 1][graph[0].length - 1]).toBe(borderSymbol); + }); + }); + + describe('addBackgroundSymbol', () => { + it('should replace empty symbols with background symbols', () => { + const emptySymbol = ' '; + graph[1][1] = emptySymbol; + + addBackgroundSymbol({ graph, backgroundSymbol, emptySymbol }); + + expect(graph[1][1]).toBe(backgroundSymbol); + }); + }); + + describe('addThresholds', () => { + it('should add thresholds correctly', () => { + const thresholds = [{ x: 1, y: 2, color: 'ansiRed' }] as Threshold[]; + const axis = { x: 0, y: 0 }; + const plotHeight = 10; + const expansionX = [0, plotWidth]; + const expansionY = [0, plotHeight]; + + addThresholds({ graph, thresholds, axis, plotWidth, plotHeight, expansionX, expansionY }); + + expect(graph[2][1]).toContain(CHART.we); + }); + it('should add color', () => { + const thresholds = [{ x: 1, y: 2, color: 'ansiBlue' }] as Threshold[]; + const axis = { x: 0, y: 0 }; + const plotHeight = 10; + const expansionX = [0, plotWidth]; + const expansionY = [0, plotHeight]; + + addThresholds({ graph, thresholds, axis, plotWidth, plotHeight, expansionX, expansionY }); + + expect(graph[2].join('')).toContain('\u001b[34m'); + }); + it('should add two thresholds ', () => { + const thresholds = [ + { x: 2, color: 'ansiBlue' }, + { x: 3, color: 'ansiRed' }, + ] as Threshold[]; + + const axis = { x: 0, y: 0 }; + const plotHeight = 10; + const expansionX = [0, plotWidth]; + const expansionY = [0, plotHeight]; + + addThresholds({ graph, thresholds, axis, plotWidth, plotHeight, expansionX, expansionY }); + + expect(graph[0][2]).toContain('\u001b[34m'); + expect(graph[0][3]).toContain('\u001b[31m'); + }); + + it('should not add color if not set', () => { + const thresholds = [{ x: 1, y: 2 }] as Threshold[]; + const axis = { x: 0, y: 0 }; + const plotHeight = 10; + const expansionX = [0, plotWidth]; + const expansionY = [0, plotHeight]; + + addThresholds({ graph, thresholds, axis, plotWidth, plotHeight, expansionX, expansionY }); + + expect(graph[2].join('')).not.toContain('\u001b[0m'); + }); + it('should not add color if not set', () => { + const thresholds = [{ x: undefined }] as Threshold[]; + const axis = { x: 0, y: 0 }; + const plotHeight = 10; + const expansionX = [0, plotWidth]; + const expansionY = [0, plotHeight]; + + addThresholds({ graph, thresholds, axis, plotWidth, plotHeight, expansionX, expansionY }); + + expect(graph).toEqual(defaultGraph); + }); + + it('should not add thresholds if its outside - x', () => { + const thresholds = [{ x: 100, color: 'ansiRed' }] as Threshold[]; + const axis = { x: 0, y: 0 }; + const plotHeight = 10; + const expansionX = [0, plotWidth]; + const expansionY = [0, plotHeight]; + + addThresholds({ graph, thresholds, axis, plotWidth, plotHeight, expansionX, expansionY }); + expect(graph.map((line) => line.join('')).join('')).not.toContain(CHART.ns); + }); + it('should not add thresholds if its outside - y', () => { + const thresholds = [{ y: 100, color: 'ansiRed' }] as Threshold[]; + const axis = { x: 0, y: 0 }; + const plotHeight = 10; + const expansionX = [0, plotWidth]; + const expansionY = [0, plotHeight]; + + addThresholds({ graph, thresholds, axis, plotWidth, plotHeight, expansionX, expansionY }); + expect(graph.map((line) => line.join('')).join('')).not.toContain(CHART.we); + }); + }); + + describe('setFillArea', () => { + it('should set fill area correctly', () => { + const chartSymbols = { nse: CHART.nse, wsn: CHART.wsn, we: CHART.we, area: 'A' }; + graph[1][1] = CHART.nse; + + setFillArea({ graph, chartSymbols }); + expect(graph[0][1]).toBe('A'); + }); + + it('should use fill area correctly', () => { + const chartSymbols = { nse: CHART.nse, wsn: CHART.wsn, we: CHART.we }; + graph[1][1] = CHART.nse; + + setFillArea({ graph, chartSymbols }); + expect(graph[0][1]).toBe(CHART.area); + }); + }); + + describe('removeEmptyLines', () => { + it('should remove empty lines correctly', () => { + const currentGraph = [ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ['g', 'h', 'i'], + [...Array(plotWidth).fill(backgroundSymbol)], + ]; + + removeEmptyLines({ graph: currentGraph, backgroundSymbol }); + + expect(currentGraph.length).toBe(3); + expect(currentGraph).toEqual([ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ['g', 'h', 'i'], + ]); + }); + }); + + describe('getTransformLabel', () => { + it('should return a formatter function', () => { + const formatter = getTransformLabel({}); + + expect(formatter).toBeInstanceOf(Function); + }); + + it('should format labels correctly with a custom formatter', () => { + const customFormatter: Formatter = (value) => `Custom: ${value}`; + const formatter = getTransformLabel({ formatter: customFormatter }); + + expect(formatter(1, {} as FormatterHelpers)).toBe('Custom: 1'); + }); + }); +}); diff --git a/src/services/defaults.ts b/src/services/defaults.ts new file mode 100644 index 0000000..7e797d0 --- /dev/null +++ b/src/services/defaults.ts @@ -0,0 +1,125 @@ +import { AXIS, EMPTY } from '../constants'; +import { Symbols, MultiLine, Formatter, Coordinates } from '../types'; +import { toArrays, getMin, getMax, toArray } from './coords'; + +export const getSymbols = ({ symbols }: { symbols?: Symbols }) => { + const axisSymbols = { ...AXIS, ...symbols?.axis }; + const emptySymbol = symbols?.empty || EMPTY; + const backgroundSymbol = symbols?.background || emptySymbol; + const borderSymbol = symbols?.border; + return { + axisSymbols, + emptySymbol, + backgroundSymbol, + borderSymbol, + }; +}; + +export const getChartSize = ({ + input, + width, + height, +}: { + input: MultiLine; + width?: number; + height?: number; +}) => { + const [rangeX, rangeY] = toArrays(input); + + const minX = getMin(rangeX); + const maxX = getMax(rangeX); + const minY = getMin(rangeY); + const maxY = getMax(rangeY); + + const expansionX = [minX, maxX]; + const expansionY = [minY, maxY]; + + // set default size + const plotWidth = width || rangeX.length; + + let plotHeight = Math.round(height || maxY - minY + 1); + + // for small values without height + if (!height && plotHeight < 3) { + plotHeight = rangeY.length; + } + + return { + minX, + plotWidth, + plotHeight, + expansionX, + expansionY, + }; +}; + +export const getLabelShift = ({ + input, + transformLabel, + expansionX, + expansionY, + minX, +}: { + input: MultiLine; + transformLabel: Formatter; + expansionX: number[]; + expansionY: number[]; + minX: number; +}) => { + let xShift = 0; + let longestY = 0; + input.forEach((current) => { + current.forEach(([pointX, pointY]) => { + xShift = Math.max( + toArray( + transformLabel(pointX, { + axis: 'x', + xRange: expansionX, + yRange: expansionY, + }), + ).length, + xShift, + ); + + longestY = Math.max( + toArray( + transformLabel(pointY, { + axis: 'y', + xRange: expansionX, + yRange: expansionY, + }), + ).length, + longestY, + ); + }); + }); + + // calculate shift for x and y labels, take the longest number + // but first format it properly because it can be trimmed + const formattedMinX = transformLabel(minX, { + axis: 'x', + xRange: expansionX, + yRange: expansionY, + }); + + // first x0 label might be longer than the yShift + // -2 is for the symbol.nse and symbol.x - at least two places for the label + const x0Shift = toArray(formattedMinX).length - 2; + const yShift = Math.max(x0Shift, longestY); + + return { + xShift, + yShift, + }; +}; + +export const getInput = ({ rawInput }: { rawInput: Coordinates }) => { + // Multiline + let input = rawInput; + + // Singleline + if (typeof input[0]?.[0] === 'number') { + input = [rawInput] as MultiLine; + } + return input as MultiLine; +}; diff --git a/src/services/draw.ts b/src/services/draw.ts new file mode 100644 index 0000000..26a42a9 --- /dev/null +++ b/src/services/draw.ts @@ -0,0 +1,279 @@ +import { AXIS, CHART } from '../constants'; +import { CustomSymbol, Formatter, Graph, MultiLine, Point, Symbols } from '../types'; +import { distance, toArray, toEmpty } from './coords'; + +export const drawXAxisEnd = ({ + hasPlaceToRender, + axisCenter, + yPos, + graph, + yShift, + i, + scaledX, + shift, + signShift, + axisSymbols, + pointXShift, +}: { + hasPlaceToRender: boolean; + axisCenter?: Point; + yPos: number; + graph: Graph; + yShift: number; + i: number; + scaledX: number; + shift: number; + signShift: number; + axisSymbols: Symbols['axis']; + pointXShift: string[]; +}) => { + const yShiftWhenOccupied = hasPlaceToRender ? -1 : 0; + const yShiftWhenHasAxisCenter = axisCenter ? 1 : 0; + + const graphY = yPos + yShiftWhenOccupied + yShiftWhenHasAxisCenter; + const graphX = scaledX + yShift - i + 2 + shift; + + graph[graphY][graphX] = pointXShift[pointXShift.length - 1 - i]; + // Add X tick only for the last value + if (pointXShift.length - 1 === i) { + graph[yPos + signShift][scaledX + yShift + 2 + shift] = axisSymbols?.x || AXIS.x; + } +}; + +export const drawYAxisEnd = ({ + graph, + scaledY, + yShift, + axis, + pointY, + transformLabel, + axisSymbols, + expansionX, + expansionY, +}: { + graph: Graph; + scaledY: number; + yShift: number; + axis: { x: number; y: number }; + pointY: number; + transformLabel: Formatter; + axisSymbols: Symbols['axis']; + expansionX: number[]; + expansionY: number[]; +}) => { + // make sure position is not taken already + if (graph[scaledY + 1][axis.x + yShift + 1] !== axisSymbols?.y) { + const pointYShift = toArray( + transformLabel(pointY, { axis: 'y', xRange: expansionX, yRange: expansionY }), + ); + for (let i = 0; i < pointYShift.length; i += 1) { + graph[scaledY + 1][axis.x + yShift - i] = pointYShift[pointYShift.length - 1 - i]; + } + graph[scaledY + 1][axis.x + yShift + 1] = axisSymbols?.y || AXIS.y; + } +}; + +export const drawAxis = ({ + graph, + hideXAxis, + hideYAxis, + axisCenter, + axisSymbols, + axis, +}: { + graph: Graph; + axis: { x: number; y: number }; + hideXAxis?: boolean; + axisCenter?: Point; + hideYAxis?: boolean; + axisSymbols: Symbols['axis']; +}) => { + graph.forEach((line, index) => { + line.forEach((_, curr) => { + let lineChar = ''; + + if (curr === axis.x && !hideYAxis) { + if (index === 0) { + lineChar = axisSymbols?.n || AXIS.n; + } else if (index === graph.length - 1 && !axisCenter && !(hideYAxis || hideXAxis)) { + lineChar = axisSymbols?.nse || AXIS.nse; + } else { + lineChar = axisSymbols?.ns || AXIS.ns; + } + } else if (index === axis.y && !hideXAxis) { + if (curr === line.length - 1) { + lineChar = axisSymbols?.e || AXIS.e; + } else { + lineChar = axisSymbols?.we || AXIS.we; + } + } + + if (lineChar) { + // eslint-disable-next-line + line[curr] = lineChar; + } + }); + }); +}; + +export const drawGraph = ({ + plotWidth, + plotHeight, + emptySymbol, +}: { + plotWidth: number; + plotHeight: number; + emptySymbol: string; +}) => { + const callback = () => toEmpty(plotWidth + 2, emptySymbol); + return Array.from({ length: plotHeight + 2 }, callback); +}; + +export const drawChart = ({ graph }: { graph: Graph }) => + `\n${graph.map((line) => line.join('')).join('\n')}\n`; + +export const drawCustomLine = ({ + sortedCoords, + scaledX, + scaledY, + input, + index, + lineFormatter, + graph, +}: { + sortedCoords: Point[]; + scaledX: number; + scaledY: number; + input: MultiLine; + index: number; + lineFormatter: (args: { + x: number; + y: number; + plotX: number; + plotY: number; + index: number; + input: Point[]; + }) => CustomSymbol | CustomSymbol[]; + graph: Graph; +}) => { + // custom line formatter + const lineFormatterArgs = { + x: sortedCoords[index][0], + y: sortedCoords[index][1], + plotX: scaledX + 1, + plotY: scaledY + 1, + index, + input: input[0], + }; + const customSymbols = lineFormatter(lineFormatterArgs); + if (Array.isArray(customSymbols)) { + customSymbols.forEach(({ x: symbolX, y: symbolY, symbol }: CustomSymbol) => { + graph[symbolY][symbolX] = symbol; + }); + } else { + graph[customSymbols.y][customSymbols.x] = customSymbols.symbol; + } +}; + +export const drawLine = ({ + index, + arr, + graph, + scaledX, + scaledY, + plotHeight, + emptySymbol, + chartSymbols, +}: { + index: number; + arr: Point[]; + graph: Graph; + scaledX: number; + scaledY: number; + plotHeight: number; + emptySymbol: string; + chartSymbols: Symbols['chart']; +}) => { + if (index - 1 >= 0) { + const [prevX, prevY] = arr[index - 1]; + const [currX, currY] = arr[index]; + + Array(distance(currY, prevY)) + .fill('') + .forEach((_, steps, array) => { + if (Math.round(prevY) > Math.round(currY)) { + graph[scaledY + 1][scaledX] = chartSymbols?.nse || CHART.nse; + if (steps === array.length - 1) { + graph[scaledY - steps][scaledX] = chartSymbols?.wns || CHART.wns; + } else { + graph[scaledY - steps][scaledX] = chartSymbols?.ns || CHART.ns; + } + } else { + graph[scaledY + steps + 2][scaledX] = chartSymbols?.wsn || CHART.wsn; + graph[scaledY + steps + 1][scaledX] = chartSymbols?.ns || CHART.ns; + } + }); + + if (Math.round(prevY) < Math.round(currY)) { + graph[scaledY + 1][scaledX] = chartSymbols?.sne || CHART.sne; + // The same Y values + } else if (Math.round(prevY) === Math.round(currY)) { + // Add line only if space is not occupied already - valid case for small similar Y + if (graph[scaledY + 1][scaledX] === emptySymbol) { + graph[scaledY + 1][scaledX] = chartSymbols?.we || CHART.we; + } + } + + const distanceX = distance(currX, prevX); + Array(distanceX ? distanceX - 1 : 0) + .fill('') + .forEach((_, steps) => { + const thisY = plotHeight - Math.round(prevY); + graph[thisY][Math.round(prevX) + steps + 1] = chartSymbols?.we || CHART.we; + }); + } + + // plot the last coordinate + if (arr.length - 1 === index) { + graph[scaledY + 1][scaledX + 1] = chartSymbols?.we || CHART.we; + } +}; + +export const drawShift = ({ + graph, + plotWidth, + emptySymbol, + scaledCoords, + xShift, + yShift, +}: { + graph: Graph; + plotWidth: number; + emptySymbol: string; + scaledCoords: number[][]; + xShift: number; + yShift: number; +}) => { + // shift graph + graph.push(toEmpty(plotWidth + 2, emptySymbol)); // bottom + + // check step + let step = plotWidth; + scaledCoords.forEach(([x], index) => { + if (scaledCoords[index - 1]) { + const current = x - scaledCoords[index - 1][0]; + step = current <= step ? current : step; + } + }); + + // x coords overlap + const hasToBeMoved = step < xShift; + if (hasToBeMoved) graph.push(toEmpty(plotWidth + 1, emptySymbol)); + + graph.forEach((line) => { + for (let i = 0; i <= yShift; i += 1) { + line.unshift(emptySymbol); // left + } + }); + return { hasToBeMoved }; +}; diff --git a/src/services/overrides.ts b/src/services/overrides.ts new file mode 100644 index 0000000..911a895 --- /dev/null +++ b/src/services/overrides.ts @@ -0,0 +1,329 @@ +import { CHART } from '../constants'; +import { Colors, Formatter, Graph, Legend, Point, Symbols, Threshold } from '../types'; +import { getPlotCoords, toArray, toEmpty, toPlot } from './coords'; +import { defaultFormatter, getAnsiColor, getChartSymbols } from './settings'; + +export const setTitle = ({ + title, + graph, + backgroundSymbol, + plotWidth, + yShift, +}: { + title: string; + graph: Graph; + backgroundSymbol: string; + plotWidth: number; + yShift: number; +}) => { + // add one line for the title + graph.unshift(toEmpty(plotWidth + yShift + 2, backgroundSymbol)); // top + Array.from(title).forEach((letter, index) => { + graph[0][index] = letter; + }); +}; + +export const addXLable = ({ + graph, + plotWidth, + yShift, + backgroundSymbol, + xLabel, +}: { + xLabel: string; + graph: Graph; + backgroundSymbol: string; + plotWidth: number; + yShift: number; +}) => { + const totalWidth = graph[0].length; + const labelLength = toArray(xLabel).length; + const startingPosition = Math.round((totalWidth - labelLength) / 2); + + // add one line for the xLabel + graph.push(toEmpty(plotWidth + yShift + 2, backgroundSymbol)); // bottom + Array.from(xLabel).forEach((letter, index) => { + graph[graph.length - 1][startingPosition + index] = letter; + }); +}; + +export const addYLabel = ({ + graph, + backgroundSymbol, + yLabel, +}: { + graph: Graph; + backgroundSymbol: string; + yLabel: string; +}) => { + const totalHeight = graph.length; + const labelLength = toArray(yLabel).length; + const startingPosition = Math.round((totalHeight - labelLength) / 2) - 1; + + const label = Array.from(yLabel); + // add one line for the xLabel + graph.forEach((line, position) => { + line.unshift(backgroundSymbol); // left + if (position > startingPosition && label[position - startingPosition - 1]) { + graph[position][0] = label[position - startingPosition - 1]; + } + }); +}; + +export const addLegend = ({ + graph, + legend, + backgroundSymbol, + color, + symbols, + fillArea, +}: { + graph: Graph; + legend: Legend; + backgroundSymbol: string; + color?: Colors; + symbols?: Symbols; + fillArea?: boolean; +}) => { + // calculate legend width as the longest label + // adds 2 for one space and color indicator + + const series = Array.isArray(legend.series) ? legend.series : [legend.series]; + const legendWidth = 2 + series.reduce((acc, label) => Math.max(acc, toArray(label).length), 0); + + // prepare space for legend + // and then place the legend + for (let i = 0; i < legendWidth; i += 1) { + graph.forEach((line, lineIndex) => { + if (legend.position === 'left') { + line.unshift(backgroundSymbol); // left + + series.forEach((label, index) => { + if (lineIndex !== index) return; + // get chart symbols for series + const chartSymbols = getChartSymbols(color, index, symbols?.chart, fillArea); + + const reversedLabel = [ + chartSymbols.area, + backgroundSymbol, + ...Array.from(label), + // add empty space to fill the legend on the left side + ...Array(legendWidth - label.length - 2).fill(backgroundSymbol), + ].reverse(); + if (reversedLabel[i]) { + // eslint-disable-next-line no-param-reassign + line[0] = reversedLabel[i]; + } + }); + } + if (legend.position === 'right') { + line.push(backgroundSymbol); + + series.forEach((label, index) => { + // get chart symbols for series + + const chartSymbols = getChartSymbols(color, index, symbols?.chart, fillArea); + const newSymbol = [ + chartSymbols.area, + backgroundSymbol, + ...Array.from(label), + // adds to fill space + ...Array(legendWidth - label.length - 2).fill(backgroundSymbol), + ]; + if (lineIndex === index) { + // eslint-disable-next-line no-param-reassign + line[line.length - 1] = newSymbol[i]; + } + }); + } + }); + } + + if (legend.position === 'top') { + series.reverse().forEach((label, index) => { + graph.unshift(toEmpty(graph[0].length, backgroundSymbol)); // top + + // get chart symbols for series + const chartSymbols = getChartSymbols(color, index, symbols?.chart, fillArea); + const newSymbol = [chartSymbols.area, backgroundSymbol, ...Array.from(label)]; + + graph[index].forEach((_, symbolIndex) => { + if (newSymbol[symbolIndex]) { + // eslint-disable-next-line no-param-reassign + graph[0][symbolIndex] = newSymbol[symbolIndex]; + } + }); + }); + } + + if (legend.position === 'bottom') { + series.forEach((label, index) => { + graph.push(toEmpty(graph[0].length, backgroundSymbol)); // bottom + + // get chart symbols for series + const chartSymbols = getChartSymbols(color, index, symbols?.chart, fillArea); + const newSymbol = [chartSymbols.area, backgroundSymbol, ...Array.from(label)]; + + graph[index].forEach((_, symbolIndex) => { + if (newSymbol[symbolIndex]) { + // eslint-disable-next-line no-param-reassign + graph[graph.length - 1][symbolIndex] = newSymbol[symbolIndex]; + } + }); + }); + } +}; + +export const addBorder = ({ graph, borderSymbol }: { graph: Graph; borderSymbol: string }) => { + graph.forEach((line) => { + line.unshift(borderSymbol); // left + line.push(borderSymbol); // right + }); + graph.unshift(toEmpty(graph[0].length, borderSymbol)); // top + graph.push(toEmpty(graph[0].length, borderSymbol)); // bottom +}; + +export const addBackgroundSymbol = ({ + graph, + backgroundSymbol, + emptySymbol, +}: { + graph: Graph; + backgroundSymbol: string; + emptySymbol: string; +}) => { + graph.forEach((line) => { + for (let index = 0; index < line.length; index += 1) { + if (line[index] === emptySymbol) { + // eslint-disable-next-line + line[index] = backgroundSymbol; + } else { + break; + } + } + }); +}; + +export const addThresholds = ({ + graph, + thresholds, + axis, + plotWidth, + plotHeight, + expansionX, + expansionY, +}: { + graph: Graph; + thresholds: Threshold[]; + axis: { x: number; y: number }; + plotWidth: number; + plotHeight: number; + expansionX: number[]; + expansionY: number[]; +}) => { + const mappedThreshold = thresholds.map(({ x: thresholdX, y: thresholdY }) => { + let { x, y } = axis; + + if (thresholdX) { + x = thresholdX; + } + if (thresholdY) { + y = thresholdY; + } + return [x, y] as Point; + }); + + // add threshold line + getPlotCoords(mappedThreshold, plotWidth, plotHeight, expansionX, expansionY).forEach( + ([x, y], thresholdNumber) => { + const [scaledX, scaledY] = toPlot(plotWidth, plotHeight)(x, y); + + // display x threshold only if it's in the graph + if (thresholds[thresholdNumber]?.x && graph[0][scaledX]) { + graph.forEach((_, index) => { + if (graph[index][scaledX]) { + graph[index][scaledX] = thresholds[thresholdNumber]?.color + ? `${getAnsiColor(thresholds[thresholdNumber]?.color || 'ansiRed')}${ + CHART.ns + }\u001b[0m` + : CHART.ns; + } + }); + } + // display y threshold only if it's in the graph + if (thresholds[thresholdNumber]?.y && graph[scaledY]) { + graph[scaledY].forEach((_, index) => { + if (graph[scaledY][index]) { + graph[scaledY][index] = thresholds[thresholdNumber]?.color + ? `${getAnsiColor(thresholds[thresholdNumber]?.color || 'ansiRed')}${ + CHART.we + }\u001b[0m` + : CHART.we; + } + }); + } + }, + ); +}; + +export const setFillArea = ({ + graph, + chartSymbols, +}: { + graph: Graph; + chartSymbols: Symbols['chart']; +}) => { + graph.forEach((xValues, yIndex) => { + xValues.forEach((xSymbol, xIndex) => { + if ( + xSymbol === chartSymbols?.nse || + xSymbol === chartSymbols?.wsn || + xSymbol === chartSymbols?.we || + xSymbol === chartSymbols?.area + ) { + if (graph[yIndex + 1]?.[xIndex]) { + graph[yIndex + 1][xIndex] = chartSymbols.area || CHART.area; + } + } + }); + }); +}; + +export const removeEmptyLines = ({ + graph, + backgroundSymbol, +}: { + graph: Graph; + backgroundSymbol: string; +}) => { + // clean up empty lines after shift + // when there are occupied positions and shift is not needed + // there might be empty lines at the bottom + const elementsToRemove: number[] = []; + graph.forEach((line, position) => { + if (line.every((symbol) => symbol === backgroundSymbol)) { + // collect empty line positions and remove them later + elementsToRemove.push(position); + } + + // remove empty lines from the beginning + if (graph.every((currentLine) => currentLine[0] === backgroundSymbol)) { + graph.forEach((currentLine) => currentLine.shift()); + } + }); + + // reverse to remove from the end, otherwise positions will be shifted + elementsToRemove.reverse().forEach((position) => { + graph.splice(position, 1); + }); +}; + +export const getTransformLabel = ({ formatter }: { formatter?: Formatter }) => { + const transformLabel: Formatter = (value, helpers) => { + if (formatter) { + return formatter(value, helpers); + } + return defaultFormatter(value, helpers); + }; + return transformLabel as Formatter; +}; diff --git a/src/services/plot.ts b/src/services/plot.ts index b6f199c..f1d9875 100644 --- a/src/services/plot.ts +++ b/src/services/plot.ts @@ -1,18 +1,30 @@ +import { getPlotCoords, toArray, toPlot, toSorted, getAxisCenter } from './coords'; +import { getChartSymbols } from './settings'; +import { SingleLine, Plot } from '../types/index'; import { - getPlotCoords, - toArrays, - getMax, - getMin, - toArray, - toPlot, - toSorted, - distance, - toEmpty, - getAxisCenter, -} from './coords'; -import { getChartSymbols, defaultFormatter, getAnsiColor } from './settings'; -import { SingleLine, MultiLine, Plot, CustomSymbol, Formatter, Point } from '../types/index'; -import { AXIS, CHART, EMPTY } from '../constants/index'; + addBackgroundSymbol, + addBorder, + addLegend, + addThresholds, + addXLable, + addYLabel, + setTitle, + setFillArea, + removeEmptyLines, + getTransformLabel, +} from './overrides'; +import { getSymbols, getChartSize, getLabelShift, getInput } from './defaults'; + +import { + drawAxis, + drawGraph, + drawChart, + drawCustomLine, + drawLine, + drawYAxisEnd, + drawXAxisEnd, + drawShift, +} from './draw'; export const plot: Plot = ( rawInput, @@ -35,55 +47,27 @@ export const plot: Plot = ( } = {}, ) => { // Multiline - let input = rawInput as MultiLine; - - // Singleline - if (typeof input[0]?.[0] === 'number') { - input = [rawInput] as MultiLine; - } + const input = getInput({ rawInput }); - // Empty + // Empty input, return early if (input.length === 0) { return ''; } - const transformLabel: Formatter = (value, helpers) => { - if (formatter) { - return formatter(value, helpers); - } - return defaultFormatter(value, helpers); - }; + const transformLabel = getTransformLabel({ formatter }); let scaledCoords = [[0, 0]]; - const [rangeX, rangeY] = toArrays(input); - - const minX = getMin(rangeX); - const maxX = getMax(rangeX); - const minY = getMin(rangeY); - const maxY = getMax(rangeY); - - const expansionX = [minX, maxX]; - const expansionY = [minY, maxY]; - - // set default size - const plotWidth = width || rangeX.length; - - let plotHeight = Math.round(height || maxY - minY + 1); - - // for small values without height - if (!height && plotHeight < 3) { - plotHeight = rangeY.length; - } - - const axisSymbols = { ...AXIS, ...symbols?.axis }; - const emptySymbol = symbols?.empty || EMPTY; - const backgroundSymbol = symbols?.background || emptySymbol; - const borderSymbol = symbols?.border; + const { minX, plotWidth, plotHeight, expansionX, expansionY } = getChartSize({ + width, + height, + input, + }); + const { axisSymbols, emptySymbol, backgroundSymbol, borderSymbol } = getSymbols({ symbols }); // create placeholder - const callback = () => toEmpty(plotWidth + 2, emptySymbol); - const graph = Array.from({ length: plotHeight + 2 }, callback); + const graph = drawGraph({ plotWidth, plotHeight, emptySymbol }); + const axis = getAxisCenter(axisCenter, plotWidth, plotHeight, expansionX, expansionY, [ 0, graph.length - 1, @@ -101,84 +85,14 @@ export const plot: Plot = ( ([x, y], index, arr) => { const [scaledX, scaledY] = toPlot(plotWidth, plotHeight)(x, y); if (!lineFormatter) { - if (index - 1 >= 0) { - const [prevX, prevY] = arr[index - 1]; - const [currX, currY] = arr[index]; - - Array(distance(currY, prevY)) - .fill('') - .forEach((_, steps, array) => { - if (Math.round(prevY) > Math.round(currY)) { - graph[scaledY + 1][scaledX] = chartSymbols.nse; - if (steps === array.length - 1) { - graph[scaledY - steps][scaledX] = chartSymbols.wns; - } else { - graph[scaledY - steps][scaledX] = chartSymbols.ns; - } - } else { - graph[scaledY + steps + 2][scaledX] = chartSymbols.wsn; - graph[scaledY + steps + 1][scaledX] = chartSymbols.ns; - } - }); - - if (Math.round(prevY) < Math.round(currY)) { - graph[scaledY + 1][scaledX] = chartSymbols.sne; - // The same Y values - } else if (Math.round(prevY) === Math.round(currY)) { - // Add line only if space is not occupied already - valid case for small similar Y - if (graph[scaledY + 1][scaledX] === emptySymbol) { - graph[scaledY + 1][scaledX] = chartSymbols.we; - } - } - - const distanceX = distance(currX, prevX); - Array(distanceX ? distanceX - 1 : 0) - .fill('') - .forEach((_, steps) => { - const thisY = plotHeight - Math.round(prevY); - graph[thisY][Math.round(prevX) + steps + 1] = chartSymbols.we; - }); - } - - // plot the last coordinate - if (arr.length - 1 === index) { - graph[scaledY + 1][scaledX + 1] = chartSymbols.we; - } + drawLine({ index, arr, graph, scaledX, scaledY, plotHeight, emptySymbol, chartSymbols }); // fill empty area under the line if fill area is true if (fillArea) { - graph.forEach((xValues, yIndex) => { - xValues.forEach((xSymbol, xIndex) => { - if ( - (xSymbol === chartSymbols.nse || - xSymbol === chartSymbols.wsn || - xSymbol === chartSymbols.we || - xSymbol === chartSymbols.area) && - graph[yIndex + 1]?.[xIndex] - ) { - graph[yIndex + 1][xIndex] = chartSymbols.area; - } - }); - }); + setFillArea({ graph, chartSymbols }); } } else { - // custom line formatter - const lineFormatterArgs = { - x: sortedCoords[index][0], - y: sortedCoords[index][1], - plotX: scaledX + 1, - plotY: scaledY + 1, - index, - input: input[0], - }; - const customSymbols = lineFormatter(lineFormatterArgs); - if (Array.isArray(customSymbols)) { - customSymbols.forEach(({ x: symbolX, y: symbolY, symbol }: CustomSymbol) => { - graph[symbolY][symbolX] = symbol; - }); - } else { - graph[customSymbols.y][customSymbols.x] = customSymbols.symbol; - } + drawCustomLine({ sortedCoords, scaledX, scaledY, input, index, lineFormatter, graph }); } return [scaledX, scaledY]; @@ -187,155 +101,46 @@ export const plot: Plot = ( }); if (thresholds) { - const mappedThreshold = thresholds.map(({ x: thresholdX, y: thresholdY }) => { - let { x, y } = axis; - - if (thresholdX) { - x = thresholdX; - } - if (thresholdY) { - y = thresholdY; - } - return [x, y] as Point; + addThresholds({ + graph, + thresholds, + axis, + plotWidth, + plotHeight, + expansionX, + expansionY, }); - - // add threshold line - getPlotCoords(mappedThreshold, plotWidth, plotHeight, expansionX, expansionY).forEach( - ([x, y], thresholdNumber) => { - const [scaledX, scaledY] = toPlot(plotWidth, plotHeight)(x, y); - - // display x threshold only if it's in the graph - if (thresholds[thresholdNumber]?.x && graph[0][scaledX]) { - graph.forEach((_, index) => { - if (graph[index][scaledX]) { - graph[index][scaledX] = thresholds[thresholdNumber]?.color - ? `${getAnsiColor(thresholds[thresholdNumber]?.color || 'ansiRed')}${ - CHART.ns - }\u001b[0m` - : CHART.ns; - } - }); - } - // display y threshold only if it's in the graph - if (thresholds[thresholdNumber]?.y && graph[scaledY]) { - graph[scaledY].forEach((_, index) => { - if (graph[scaledY][index]) { - graph[scaledY][index] = thresholds[thresholdNumber]?.color - ? `${getAnsiColor(thresholds[thresholdNumber]?.color || 'ansiRed')}${ - CHART.we - }\u001b[0m` - : CHART.we; - } - }); - } - }, - ); } // axis - graph.forEach((line, index) => { - line.forEach((_, curr) => { - let lineChar = ''; - - if (curr === axis.x && !hideYAxis) { - if (index === 0) { - lineChar = axisSymbols.n; - } else if (index === graph.length - 1 && !axisCenter && !(hideYAxis || hideXAxis)) { - lineChar = axisSymbols.nse; - } else { - lineChar = axisSymbols.ns; - } - } else if (index === axis.y && !hideXAxis) { - if (curr === line.length - 1) { - lineChar = axisSymbols.e; - } else { - lineChar = axisSymbols.we; - } - } - - if (lineChar) { - // eslint-disable-next-line - line[curr] = lineChar; - } - }); + drawAxis({ + graph, + hideXAxis, + hideYAxis, + axisCenter, + axisSymbols, + axis, }); // labels - // calculate shift for x and y labels, take the longest number - // but first format it properly because it can be trimmed - const formattedMinX = transformLabel(minX, { axis: 'x', xRange: expansionX, yRange: expansionY }); - // takes the longest label that needs to be rendered // on the Y axis and returns it's length - - let xShift = 0; - let longestY = 0; - input.forEach((current) => { - current.forEach(([pointX, pointY]) => { - xShift = Math.max( - toArray( - transformLabel(pointX, { - axis: 'x', - xRange: expansionX, - yRange: expansionY, - }), - ).length, - xShift, - ); - - longestY = Math.max( - toArray( - transformLabel(pointY, { - axis: 'y', - xRange: expansionX, - yRange: expansionY, - }), - ).length, - longestY, - ); - }); - }); - - // first x0 label might be longer than the yShift - // -2 is for the symbol.nse and symbol.x - at least two places for the label - const x0Shift = toArray(formattedMinX).length - 2; - const yShift = Math.max(x0Shift, longestY); + const { xShift, yShift } = getLabelShift({ input, transformLabel, expansionX, expansionY, minX }); // shift graph - graph.push(toEmpty(plotWidth + 2, emptySymbol)); // bottom - - // check step - let step = plotWidth; - scaledCoords.forEach(([x], index) => { - if (scaledCoords[index - 1]) { - const current = x - scaledCoords[index - 1][0]; - step = current <= step ? current : step; - } + const { hasToBeMoved } = drawShift({ + graph, + plotWidth, + emptySymbol, + scaledCoords, + xShift, + yShift, }); - // x coords overlap - const hasToBeMoved = step < xShift; - if (hasToBeMoved) graph.push(toEmpty(plotWidth + 1, emptySymbol)); - - graph.forEach((line) => { - for (let i = 0; i <= yShift; i += 1) { - line.unshift(emptySymbol); // left - } - }); - - // apply background symbol if overrided + // apply background symbol if override if (backgroundSymbol) { - graph.forEach((line) => { - for (let index = 0; index < line.length; index += 1) { - if (line[index] === emptySymbol) { - // eslint-disable-next-line - line[index] = backgroundSymbol; - } else { - break; - } - } - }); + addBackgroundSymbol({ graph, backgroundSymbol, emptySymbol }); } // shift coords @@ -346,16 +151,17 @@ export const plot: Plot = ( const [scaledX, scaledY] = toPlot(plotWidth, plotHeight)(x, y); if (!hideYAxis) { - // make sure position is not taken already - if (graph[scaledY + 1][axis.x + yShift + 1] !== axisSymbols.y) { - const pointYShift = toArray( - transformLabel(pointY, { axis: 'y', xRange: expansionX, yRange: expansionY }), - ); - for (let i = 0; i < pointYShift.length; i += 1) { - graph[scaledY + 1][axis.x + yShift - i] = pointYShift[pointYShift.length - 1 - i]; - } - graph[scaledY + 1][axis.x + yShift + 1] = axisSymbols.y; - } + drawYAxisEnd({ + graph, + scaledY, + yShift, + axis, + pointY, + transformLabel, + axisSymbols, + expansionX, + expansionY, + }); } if (!hideXAxis) { @@ -390,184 +196,74 @@ export const plot: Plot = ( if (axisCenter) { yPos = axis.y + 1; } - - const yShiftWhenOccupied = hasPlaceToRender ? -1 : 0; - const yShiftWhenHasAxisCenter = axisCenter ? 1 : 0; - - const graphY = yPos + yShiftWhenOccupied + yShiftWhenHasAxisCenter; - const graphX = scaledX + yShift - i + 2 + shift; - - graph[graphY][graphX] = pointXShift[pointXShift.length - 1 - i]; - // Add X tick only for the last value - if (pointXShift.length - 1 === i) { - graph[yPos + signShift][scaledX + yShift + 2 + shift] = axisSymbols.x; - } + drawXAxisEnd({ + hasPlaceToRender, + axisCenter, + yPos, + graph, + yShift, + i, + scaledX, + shift, + signShift, + axisSymbols, + pointXShift, + }); } } }); }); // Remove empty lines - - // clean up empty lines after shift - // when there are occupied positions and shift is not needed - // there might be empty lines at the bottom - const elementsToRemove: number[] = []; - graph.forEach((line, position) => { - if (line.every((symbol) => symbol === backgroundSymbol)) { - // collect empty line positions and remove them later - elementsToRemove.push(position); - } - - // remove empty lines from the beginning - if (graph.every((currentLine) => currentLine[0] === backgroundSymbol)) { - graph.forEach((currentLine) => currentLine.shift()); - } - }); - - // reverse to remove from the end, otherwise positions will be shifted - elementsToRemove.reverse().forEach((position) => { - graph.splice(position, 1); - }); + removeEmptyLines({ graph, backgroundSymbol }); // Adds title above the graph if (title) { - // add one line for the title - graph.unshift(toEmpty(plotWidth + yShift + 2, backgroundSymbol)); // top - Array.from(title).forEach((letter, index) => { - graph[0][index] = letter; + setTitle({ + title, + graph, + backgroundSymbol, + plotWidth, + yShift, }); } // Adds x axis label below the graph if (xLabel) { - const totalWidth = graph[0].length; - const labelLength = toArray(xLabel).length; - const startingPosition = Math.round((totalWidth - labelLength) / 2); - - // add one line for the xLabel - graph.push(toEmpty(plotWidth + yShift + 2, backgroundSymbol)); // bottom - Array.from(xLabel).forEach((letter, index) => { - graph[graph.length - 1][startingPosition + index] = letter; + addXLable({ + xLabel, + graph, + backgroundSymbol, + plotWidth, + yShift, }); } // Adds x axis label below the graph if (yLabel) { - const totalHeight = graph.length; - const labelLength = toArray(yLabel).length; - const startingPosition = Math.round((totalHeight - labelLength) / 2) - 1; - - const label = Array.from(yLabel); - // add one line for the xLabel - graph.forEach((line, position) => { - line.unshift(backgroundSymbol); // left - if (position > startingPosition && label[position - startingPosition - 1]) { - graph[position][0] = label[position - startingPosition - 1]; - } + addYLabel({ + yLabel, + graph, + backgroundSymbol, }); } if (legend) { - // calculate legend width as the longest label - // adds 2 for one space and color indicator - - const series = Array.isArray(legend.series) ? legend.series : [legend.series]; - const legendWidth = 2 + series.reduce((acc, label) => Math.max(acc, toArray(label).length), 0); - - // prepare space for legend - // and then place the legend - for (let i = 0; i < legendWidth; i += 1) { - graph.forEach((line, lineIndex) => { - if (legend.position === 'left') { - line.unshift(backgroundSymbol); // left - - series.forEach((label, index) => { - if (lineIndex !== index) return; - // get chart symbols for series - const chartSymbols = getChartSymbols(color, index, symbols?.chart, fillArea); - - const reversedLabel = [ - chartSymbols.area, - backgroundSymbol, - ...Array.from(label), - // add empty space to fill the legend on the left side - ...Array(legendWidth - label.length - 2).fill(backgroundSymbol), - ].reverse(); - if (reversedLabel[i]) { - // eslint-disable-next-line no-param-reassign - line[0] = reversedLabel[i]; - } - }); - } - if (legend.position === 'right') { - line.push(backgroundSymbol); - - series.forEach((label, index) => { - // get chart symbols for series - - const chartSymbols = getChartSymbols(color, index, symbols?.chart, fillArea); - const newSymbol = [ - chartSymbols.area, - backgroundSymbol, - ...Array.from(label), - // adds to fill space - ...Array(legendWidth - label.length - 2).fill(backgroundSymbol), - ]; - if (lineIndex === index) { - // eslint-disable-next-line no-param-reassign - line[line.length - 1] = newSymbol[i]; - } - }); - } - }); - } - - if (legend.position === 'top') { - series.reverse().forEach((label, index) => { - graph.unshift(toEmpty(graph[0].length, backgroundSymbol)); // top - - // get chart symbols for series - const chartSymbols = getChartSymbols(color, index, symbols?.chart, fillArea); - const newSymbol = [chartSymbols.area, backgroundSymbol, ...Array.from(label)]; - - graph[index].forEach((_, symbolIndex) => { - if (newSymbol[symbolIndex]) { - // eslint-disable-next-line no-param-reassign - graph[0][symbolIndex] = newSymbol[symbolIndex]; - } - }); - }); - } - - if (legend.position === 'bottom') { - series.forEach((label, index) => { - graph.push(toEmpty(graph[0].length, backgroundSymbol)); // bottom - - // get chart symbols for series - const chartSymbols = getChartSymbols(color, index, symbols?.chart, fillArea); - const newSymbol = [chartSymbols.area, backgroundSymbol, ...Array.from(label)]; - - graph[index].forEach((_, symbolIndex) => { - if (newSymbol[symbolIndex]) { - // eslint-disable-next-line no-param-reassign - graph[graph.length - 1][symbolIndex] = newSymbol[symbolIndex]; - } - }); - }); - } + addLegend({ + graph, + legend, + backgroundSymbol, + color, + symbols, + fillArea, + }); } if (borderSymbol) { - graph.forEach((line) => { - line.unshift(borderSymbol); // left - line.push(borderSymbol); // right - }); - graph.unshift(toEmpty(graph[0].length, borderSymbol)); // top - graph.push(toEmpty(graph[0].length, borderSymbol)); // bottom + addBorder({ graph, borderSymbol }); } - return `\n${graph.map((line) => line.join('')).join('\n')}\n`; + return drawChart({ graph }); }; export default plot; diff --git a/src/types/index.ts b/src/types/index.ts index 49b1850..7342631 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -33,10 +33,25 @@ export type FormatterHelpers = { yRange: number[]; }; -export type Formatter = (number: number, helpers: FormatterHelpers) => number | string; +export type Symbols = { + axis?: Partial; + chart?: Partial; + empty?: string; + background?: string; + border?: string; +}; +export type Formatter = (number: number, helpers: FormatterHelpers) => number | string; +export type Legend = { position?: 'left' | 'right' | 'top' | 'bottom'; series: string | string[] }; +export type Threshold = { + x?: number; + y?: number; + color?: Color; +}; +export type Colors = Color | Color[]; +export type Graph = string[][]; export type Settings = { - color?: Color | Color[]; + color?: Colors; width?: number; height?: number; hideXAxis?: boolean; @@ -44,23 +59,13 @@ export type Settings = { title?: string; xLabel?: string; yLabel?: string; - thresholds?: { - x?: number; - y?: number; - color?: Color; - }[]; + thresholds?: Threshold[]; fillArea?: boolean; - legend?: { position?: 'left' | 'right' | 'top' | 'bottom'; series: string | string[] }; + legend?: Legend; axisCenter?: Point; formatter?: Formatter; lineFormatter?: (args: LineFormatterArgs) => CustomSymbol | CustomSymbol[]; - symbols?: { - axis?: Partial; - chart?: Partial; - empty?: string; - background?: string; - border?: string; - }; + symbols?: Symbols; }; export type Plot = (coordinates: Coordinates, settings?: Settings) => string; diff --git a/yarn.lock b/yarn.lock index 73d83bc..2a07575 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1239,6 +1239,15 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -4037,7 +4046,7 @@ yaml@2.3.1: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== -yargs-parser@^21.0.0, yargs-parser@^21.0.1: +yargs-parser@^21.0.0, yargs-parser@^21.0.1, yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== @@ -4055,6 +4064,19 @@ yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.0.0" +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"