diff --git a/.eslintrc.json b/.eslintrc.json index 8a82ca0..3dbb6ff 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,6 +18,8 @@ "args": "none", "ignoreRestSiblings": false } - ] + ], + "semi": ["error", "never"], + "object-curly-spacing": ["error", "never"] } } diff --git a/example.js b/example.js index 53c0dc2..c7a41b4 100644 --- a/example.js +++ b/example.js @@ -1,3 +1,4 @@ +// @ts-check /* eslint-env node */ import stringify from 'virtual-dom-stringify' @@ -41,6 +42,6 @@ process.stdout.write(` .shape.nexus { fill: #cc333f; stroke: #cc333f; } .shape.galaxy { fill: #00a0b0; stroke: #00a0b0; } - ${stringify(chart)} + ${stringify(chart, undefined)} `) diff --git a/index.js b/index.js index 4e7ec37..7121ff3 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,88 @@ -import h from 'virtual-dom/h.js' +// @ts-check -// helpers +import h from 'virtual-dom/h.js' +// Type definitions for a Point and Points to improve clarity and reusability in the code +/** + * @typedef {[number, number]} Point + * Represents a point in 2D space as a tuple of two numbers [x, y]. + */ + +/** + * @typedef {Point[]} Points + * Represents an array of Points, i.e., a path or a shape in 2D space. + */ + +// Data type definition for use with radar chart columns +/** + * @template {Exclude} T + * @typedef {Record & { class: string }} Data + * Represents a record with keys of type T, and values of type string or number, with a special 'class' key that must be a string. + */ + +// Column type definition with generics, excluding 'class' from keys +/** + * @template {Exclude} T + * @typedef {Object} Column + * @property {T} key - The unique key for identifying each column. + * @property {string} caption - The caption that will be displayed on each axis. + * @property {number} angle - The angle at which the axis is displayed. + */ + +// Options type definition including methods for different visual components +/** + * @template {Exclude} T + * @typedef {Object} Options + * @property {number} size - The overall size of the radar chart. + * @property {number} scales - The number of concentric circles (scales) to show. + * @property {boolean} axes - Whether to display the axes. + * @property {boolean} captions - Whether to display the captions. + * @property {number} captionsPosition - The radial position of the captions. + * @property {(points: Points) => string} smoothing - Function to smooth the path between points. + * @property {(col: Column) => { className: string } & import('react').SVGProps} axisProps - Function to define properties for axis elements. + * @property {(scale: number) => { className: string } & import('react').SVGProps} scaleProps - Function to define properties for scale elements. + * @property {(col: Data) => { className: string } & import('react').SVGProps} shapeProps - Function to define properties for shape elements. + * @property {(col: Column) => { className: string } & import('react').SVGProps} captionProps - Function to define properties for caption elements. + */ + +// Extends basic options with additional chart size property +/** + * @template {Exclude} T + * @typedef {Options & { chartSize: number }} ExtendedOptions + */ + +// Converts polar coordinates to Cartesian X coordinate +/** + * @param {number} angle - The angle in radians. + * @param {number} distance - The distance from the origin. + * @returns {number} The X coordinate. + */ const polarToX = (angle, distance) => Math.cos(angle - Math.PI / 2) * distance +// Converts polar coordinates to Cartesian Y coordinate +/** + * @param {number} angle - The angle in radians. + * @param {number} distance - The distance from the origin. + * @returns {number} The Y coordinate. + */ const polarToY = (angle, distance) => Math.sin(angle - Math.PI / 2) * distance +// Converts an array of Points into a space-separated string for SVG paths +/** + * @param {Points} points - An array of Points. + * @returns {string} A string representing the 'points' attribute in SVG. + */ const points = (points) => { return points - .map(point => point[0].toFixed(4) + ',' + point[1].toFixed(4)) - .join(' ') + .map(point => point[0].toFixed(4) + ',' + point[1].toFixed(4)) + .join(' ') } +// Creates an SVG path data string with no smoothing (just straight lines) +/** + * @param {Points} points - An array of Points to connect with lines. + * @returns {string} An SVG path data string. + */ const noSmoothing = (points) => { let d = 'M' + points[0][0].toFixed(4) + ',' + points[0][1].toFixed(4) for (let i = 1; i < points.length; i++) { @@ -20,12 +91,17 @@ const noSmoothing = (points) => { return d + 'z' } - - +// Returns a function that generates SVG polyline elements for each axis +/** + * @template {Exclude} T + * @param {ExtendedOptions} opt - The options for rendering the radar chart. + * @returns {(col: Column) => string} A function that returns SVG polyline elements for each axis. + */ const axis = (opt) => (col) => { return h('polyline', Object.assign(opt.axisProps(col), { points: points([ - [0, 0], [ + [0, 0], + [ polarToX(col.angle, opt.chartSize / 2), polarToY(col.angle, opt.chartSize / 2) ] @@ -33,11 +109,18 @@ const axis = (opt) => (col) => { })) } +// Returns a function that generates SVG path elements for each data shape +/** + * @template {Exclude} T + * @param {Column[]} columns - The columns defining the axes of the radar chart. + * @param {ExtendedOptions} opt - The options for rendering the radar chart. + * @returns {(data: Data, i: number) => Point} A function that returns SVG path elements for each data shape. + */ const shape = (columns, opt) => (data, i) => { return h('path', Object.assign(opt.shapeProps(data), { d: opt.smoothing(columns.map((col) => { const val = data[col.key] - if ('number' !== typeof val) { + if (typeof val !== 'number') { throw new Error(`Data set ${i} is invalid.`) } @@ -49,73 +132,97 @@ const shape = (columns, opt) => (data, i) => { })) } +// Returns an SVG circle element for each scale +/** + * @template {Exclude} T + * @param {ExtendedOptions} opt - The options for rendering the radar chart. + * @param {number} value - The value at which the scale is placed. + * @returns {string} An SVG circle element for the scale. + */ const scale = (opt, value) => { return h('circle', Object.assign(opt.scaleProps(value), { cx: 0, cy: 0, r: value * opt.chartSize / 2 })) } +// Returns a function that generates SVG text elements for each caption +/** + * @template {Exclude} T + * @param {ExtendedOptions} opt - The options for rendering the radar chart. + * @returns {(col: Column) => string} A function that returns SVG text elements for each caption. + */ const caption = (opt) => (col) => { return h('text', Object.assign(opt.captionProps(col), { - x: polarToX(col.angle, opt.size / 2 * .95).toFixed(4), - y: polarToY(col.angle, opt.size / 2 * .95).toFixed(4), - dy: (opt.captionProps(col).fontSize || 2) / 2 + x: polarToX(col.angle, opt.size / 2 * 0.95).toFixed(4), + y: polarToY(col.angle, opt.size / 2 * 0.95).toFixed(4), + dy: (parseInt(opt.captionProps(col).fontSize + '') || 2) / 2 }), col.caption) } - - -const defaults = { +// Default configuration options for the radar chart +const defaults = /** @type {Options} */ ({ size: 100, // size of the chart (including captions) - axes: true, // show axes? - scales: 3, // show scale circles? - captions: true, // show captions? - captionsPosition: 1.2, // where on the axes are the captions? - smoothing: noSmoothing, // shape smoothing function - axisProps: () => ({className: 'axis'}), - scaleProps: () => ({className: 'scale', fill: 'none'}), - shapeProps: () => ({className: 'shape'}), - captionProps: () => ({ + axes: true, // whether to show axes + scales: 3, // number of concentric scales to show + captions: true, // whether to show captions + captionsPosition: 1.2, // radial position of captions + smoothing: noSmoothing, // default smoothing function + axisProps: () => ({className: 'axis'}), // default axis properties + scaleProps: () => ({className: 'scale', fill: 'none'}), // default scale properties + shapeProps: () => ({className: 'shape'}), // default shape properties + captionProps: () => ({ // default caption properties className: 'caption', textAnchor: 'middle', fontSize: 3, fontFamily: 'sans-serif' }) -} - -const renderRadarChart = (columns, data, opt = {}) => { - if ('object' !== typeof columns || Array.isArray(columns)) { +}) + +// Main function to render the radar chart with given columns and data +/** + * @template {Exclude} T + * @param {Record, string>} columnsData - The data for the columns. + * @param {Data[]} data - The array of data to be plotted. + * @param {Partial>} opt - Additional options to override the defaults. + * @returns {string} The rendered radar chart as an SVG element. + */ +const renderRadarChart = (columnsData, data, opt = {}) => { + if (typeof columnsData !== 'object' || Array.isArray(columnsData)) { throw new Error('columns must be an object') } if (!Array.isArray(data)) { throw new Error('data must be an array') } - opt = Object.assign({}, defaults, opt) - opt.chartSize = opt.size / opt.captionsPosition + if (data.some(data => Object.keys(data).some(key => key !== 'class' && typeof data[key] !== 'number'))) { + throw new Error('data must contain set of numbers') + } + const options = /** @type {ExtendedOptions} */({...defaults, ...opt, chartSize: 0}) + options.chartSize = options.size / options.captionsPosition - columns = Object.keys(columns).map((key, i, all) => ({ - key, caption: columns[key], + const columns = /** @type {Column[]} */ (Object.keys(columnsData).map((key, i, all) => ({ + key, caption: columnsData[key], angle: Math.PI * 2 * i / all.length - })) + }))) const groups = [ - h('g', data.map(shape(columns, opt))) + h('g', data.map(shape(columns, options))) ] - if (opt.captions) groups.push(h('g', columns.map(caption(opt)))) - if (opt.axes) groups.unshift(h('g', columns.map(axis(opt)))) - if (opt.scales > 0) { + if (options.captions) groups.push(h('g', columns.map(caption(options)))) + if (options.axes) groups.unshift(h('g', columns.map(axis(options)))) + if (options.scales > 0) { const scales = [] - for (let i = opt.scales; i > 0; i--) { - scales.push(scale(opt, i / opt.scales)) + for (let i = options.scales; i > 0; i--) { + scales.push(scale(options, i / options.scales)) } groups.unshift(h('g', scales)) } - const delta = (opt.size / 2).toFixed(4) + const delta = (options.size / 2).toFixed(4) return h('g', { transform: `translate(${delta},${delta})` }, groups) } +// Export the radar chart rendering function export { renderRadarChart as radar, } diff --git a/package.json b/package.json index 3cd58db..c539909 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,10 @@ "eslint": "^8.56.0", "safe-eval": "^0.4.1", "uglify-es": "^3.3.9", - "virtual-dom-stringify": "^3.0.1" + "virtual-dom-stringify": "^3.0.1", + "@types/d3-shape": "^1.0.3", + "@types/virtual-dom": "^2.1.1", + "@types/react": "^18.3.2" }, "scripts": { "lint": "eslint .",