From 8fa00c029404b4d7325c6816d6fe217ac7059cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Billioud?= Date: Wed, 22 May 2024 01:24:50 +0200 Subject: [PATCH 1/5] Add types --- example.js | 3 +- index.js | 257 ++++++++++++++++++++++++++++++++++++--------------- package.json | 5 +- 3 files changed, 187 insertions(+), 78 deletions(-) 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..cf57103 100644 --- a/index.js +++ b/index.js @@ -1,121 +1,226 @@ -import h from 'virtual-dom/h.js' - -// helpers +// @ts-check -const polarToX = (angle, distance) => Math.cos(angle - Math.PI / 2) * distance - -const polarToY = (angle, distance) => Math.sin(angle - Math.PI / 2) * distance +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) + let d = 'M' + points[0][0].toFixed(4) + ',' + points[0][1].toFixed(4); for (let i = 1; i < points.length; i++) { - d += 'L' + points[i][0].toFixed(4) + ',' + points[i][1].toFixed(4) + d += 'L' + points[i][0].toFixed(4) + ',' + points[i][1].toFixed(4); } - return d + 'z' -} - - - + 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], [ - polarToX(col.angle, opt.chartSize / 2), - polarToY(col.angle, opt.chartSize / 2) - ] + [0, 0], + [polarToX(col.angle, opt.chartSize / 2), + polarToY(col.angle, opt.chartSize / 2)] ]) - })) -} - + })); +}; + +// 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) { - throw new Error(`Data set ${i} is invalid.`) + const val = data[col.key]; + if (typeof val !== 'number') { + throw new Error(`Data set ${i} is invalid.`); } return [ polarToX(col.angle, val * opt.chartSize / 2), polarToY(col.angle, val * opt.chartSize / 2) - ] + ]; })) - })) -} - + })); +}; + +// 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 - }), col.caption) -} - - - -const defaults = { + 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); +}; + +// 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)) { - throw new Error('columns must be an object') +}); + +// 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') + throw new Error('data must be an array'); + } + if (data.some(data => Object.keys(key => key !== 'class' && typeof data[key] !== 'number'))) { + throw new Error('data must contain set of numbers'); } - opt = Object.assign({}, defaults, opt) - opt.chartSize = opt.size / opt.captionsPosition + 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))) - ] - 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) { - const scales = [] - for (let i = opt.scales; i > 0; i--) { - scales.push(scale(opt, i / opt.scales)) + h('g', data.map(shape(columns, options))) + ]; + 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 = options.scales; i > 0; i--) { + scales.push(scale(options, i / options.scales)); } - groups.unshift(h('g', 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) -} + }, 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 .", From c5bdc68bd5faca3b0f62ca45572e70a34fc81c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Billioud?= Date: Wed, 22 May 2024 08:25:28 +0200 Subject: [PATCH 2/5] Fix typo --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index cf57103..cbd8f2b 100644 --- a/index.js +++ b/index.js @@ -190,7 +190,7 @@ const renderRadarChart = (columnsData, data, opt = {}) => { if (!Array.isArray(data)) { throw new Error('data must be an array'); } - if (data.some(data => Object.keys(key => key !== 'class' && typeof data[key] !== 'number'))) { + if (data.some(data => Object.keys(data).map(key => key !== 'class' && typeof data[key] !== 'number'))) { throw new Error('data must contain set of numbers'); } const options = /** @type {ExtendedOptions} */({ ...defaults, ...opt, chartSize: 0 }); From 0534eab9c78dd449bd6cf1ab72424107f5be60af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Billioud?= Date: Wed, 22 May 2024 08:31:44 +0200 Subject: [PATCH 3/5] Fix another typo --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index cbd8f2b..9aa7672 100644 --- a/index.js +++ b/index.js @@ -190,7 +190,7 @@ const renderRadarChart = (columnsData, data, opt = {}) => { if (!Array.isArray(data)) { throw new Error('data must be an array'); } - if (data.some(data => Object.keys(data).map(key => key !== 'class' && typeof data[key] !== 'number'))) { + 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 }); From 5980b74d44cd162bb056a6677a227a5411a8a5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Billioud?= Date: Thu, 30 May 2024 12:25:09 +0200 Subject: [PATCH 4/5] Fix linting --- .eslintrc.json | 4 ++- index.js | 86 ++++++++++++++++++++++++++------------------------ 2 files changed, 48 insertions(+), 42 deletions(-) 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/index.js b/index.js index 9aa7672..02960b8 100644 --- a/index.js +++ b/index.js @@ -57,7 +57,7 @@ import h from 'virtual-dom/h.js' * @param {number} distance - The distance from the origin. * @returns {number} The X coordinate. */ -const polarToX = (angle, distance) => Math.cos(angle - Math.PI / 2) * distance; +const polarToX = (angle, distance) => Math.cos(angle - Math.PI / 2) * distance // Converts polar coordinates to Cartesian Y coordinate /** @@ -65,7 +65,7 @@ const polarToX = (angle, distance) => Math.cos(angle - Math.PI / 2) * distance; * @param {number} distance - The distance from the origin. * @returns {number} The Y coordinate. */ -const polarToY = (angle, distance) => Math.sin(angle - Math.PI / 2) * distance; +const polarToY = (angle, distance) => Math.sin(angle - Math.PI / 2) * distance // Converts an array of Points into a space-separated string for SVG paths /** @@ -75,8 +75,8 @@ const polarToY = (angle, distance) => Math.sin(angle - Math.PI / 2) * distance; const points = (points) => { return points .map(point => point[0].toFixed(4) + ',' + point[1].toFixed(4)) - .join(' '); -}; + .join(' ') +} // Creates an SVG path data string with no smoothing (just straight lines) /** @@ -84,12 +84,12 @@ const points = (points) => { * @returns {string} An SVG path data string. */ const noSmoothing = (points) => { - let d = 'M' + points[0][0].toFixed(4) + ',' + points[0][1].toFixed(4); + let d = 'M' + points[0][0].toFixed(4) + ',' + points[0][1].toFixed(4) for (let i = 1; i < points.length; i++) { - d += 'L' + points[i][0].toFixed(4) + ',' + points[i][1].toFixed(4); + d += 'L' + points[i][0].toFixed(4) + ',' + points[i][1].toFixed(4) } - return d + 'z'; -}; + return d + 'z' +} // Returns a function that generates SVG polyline elements for each axis /** @@ -101,11 +101,13 @@ const axis = (opt) => (col) => { return h('polyline', Object.assign(opt.axisProps(col), { points: points([ [0, 0], - [polarToX(col.angle, opt.chartSize / 2), - polarToY(col.angle, opt.chartSize / 2)] + [ + polarToX(col.angle, opt.chartSize / 2), + polarToY(col.angle, opt.chartSize / 2) + ] ]) - })); -}; + })) +} // Returns a function that generates SVG path elements for each data shape /** @@ -117,18 +119,18 @@ const axis = (opt) => (col) => { 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]; + const val = data[col.key] if (typeof val !== 'number') { - throw new Error(`Data set ${i} is invalid.`); + throw new Error(`Data set ${i} is invalid.`) } return [ polarToX(col.angle, val * opt.chartSize / 2), polarToY(col.angle, val * opt.chartSize / 2) - ]; + ] })) - })); -}; + })) +} // Returns an SVG circle element for each scale /** @@ -140,8 +142,8 @@ const shape = (columns, opt) => (data, i) => { 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 /** @@ -154,8 +156,8 @@ const caption = (opt) => (col) => { 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); -}; + }), col.caption) +} // Default configuration options for the radar chart const defaults = /** @type {Options} */ ({ @@ -165,15 +167,15 @@ const defaults = /** @type {Options} */ ({ 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 + 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' }) -}); +}) // Main function to render the radar chart with given columns and data /** @@ -185,42 +187,44 @@ const defaults = /** @type {Options} */ ({ */ const renderRadarChart = (columnsData, data, opt = {}) => { if (typeof columnsData !== 'object' || Array.isArray(columnsData)) { - throw new Error('columns must be an object'); + throw new Error('columns must be an object') } if (!Array.isArray(data)) { - throw new Error('data must be an array'); + throw new Error('data must be an array') } if (data.some(data => Object.keys(data).some(key => key !== 'class' && typeof data[key] !== 'number'))) { - throw new Error('data must contain set of numbers'); + throw new Error('data must contain set of numbers') } - const options = /** @type {ExtendedOptions} */({ ...defaults, ...opt, chartSize: 0 }); - options.chartSize = options.size / options.captionsPosition; + const options = /** @type {ExtendedOptions} */({...defaults, ...opt, chartSize: 0}) + options.chartSize = options.size / options.captionsPosition 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, options))) - ]; - if (options.captions) groups.push(h('g', columns.map(caption(options)))); - if (options.axes) groups.unshift(h('g', columns.map(axis(options)))); + ] + 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 = []; + const scales = [] for (let i = options.scales; i > 0; i--) { - scales.push(scale(options, i / options.scales)); + scales.push(scale(options, i / options.scales)) } - groups.unshift(h('g', scales)); + groups.unshift(h('g', scales)) } - const delta = (options.size / 2).toFixed(4); + const delta = (options.size / 2).toFixed(4) return h('g', { transform: `translate(${delta},${delta})` - }, groups); -}; + }, groups) +} // Export the radar chart rendering function export { renderRadarChart as radar, -}; +} + +export * from './smoothing.js' From 1d7e6a5cbc7344d7365f83d95e1a8fff2ef8e42a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Billioud?= Date: Fri, 31 May 2024 18:23:17 +0200 Subject: [PATCH 5/5] Remove smoothing export --- index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/index.js b/index.js index 02960b8..7121ff3 100644 --- a/index.js +++ b/index.js @@ -226,5 +226,3 @@ const renderRadarChart = (columnsData, data, opt = {}) => { export { renderRadarChart as radar, } - -export * from './smoothing.js'