From fa84b4e91f5c63e586dccdaeea7146d23cf0a8db Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 22 Feb 2025 00:35:44 +0100 Subject: [PATCH 01/12] Enhance bubble chart styling and theme management --- README.md | 10 +++- src/chart/generator.ts | 10 +++- src/chart/styles.ts | 15 +++-- src/chart/themes.ts | 50 ++++++++++------ src/common/utils.ts | 11 +++- tests/chart/styles.test.ts | 11 ++-- tests/chart/themes.test.ts | 33 +++++------ tests/common/utils.test.ts | 115 +++++++++++++++++++++++++++++++++++++ 8 files changed, 204 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 1ec8f6b..7ea8f5e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitHub Bubble Chart +# 🫧 GitHub Bubble Chart [![CI](https://github.com/teociaps/github-bubble-chart/actions/workflows/ci.yml/badge.svg)](https://github.com/teociaps/github-bubble-chart/actions/workflows/ci.yml) [![Test and Lint](https://github.com/teociaps/github-bubble-chart/actions/workflows/test.yml/badge.svg)](https://github.com/teociaps/github-bubble-chart/actions/workflows/test.yml) @@ -26,11 +26,15 @@ For detailed usage instructions, please refer to our [Wiki](https://github.com/t #### Basic Example -![teociaps](https://github-bubble-chart.vercel.app?username=teociaps&theme=dark_high_contrast&title-size=34&title-color=red&legend-align=left) +![teociaps](https://github-bubble-chart.vercel.app?username=teociaps&theme=dark_dimmed&title-size=34&title-color=red&legend-align=left) #### Custom Configuration Example -![Test](https://github-bubble-chart.vercel.app/?username=teociaps&mode=custom-config&config-path=ghbc-my-tech-and-tools.json) +![Custom](https://github-bubble-chart.vercel.app/?username=teociaps&mode=custom-config&config-path=ghbc-my-tech-and-tools.json) + +``` +https://github-bubble-chart.vercel.app?username=teociap&mode=custom-config&config-path=ghbc-my-tech-and-tools.json +``` ## Breaking Changes & Releases diff --git a/src/chart/generator.ts b/src/chart/generator.ts index 7571cd7..bb57261 100644 --- a/src/chart/generator.ts +++ b/src/chart/generator.ts @@ -4,6 +4,7 @@ import { getCommonStyles, generateBubbleAnimationStyle, getLegendItemAnimationStyle, + chartPadding, } from './styles.js'; import { BubbleData } from './types/bubbleData.js'; import { BubbleChartOptions, TitleOptions } from './types/chartOptions.js'; @@ -329,18 +330,21 @@ export async function createBubbleChart( } // Start building the SVG - let svg = ``; + const borderPx = chartOptions.theme?.border?.width || 0; + const borderColor = chartOptions.theme?.border?.color || 'transparent'; + let svg = ``; svg += createSVGDefs(); + svg += ``; + svg += ``; svg += svgTitle; svg += ``; - for await (const [index, element] of bubbleNodes.entries()) { svg += await createBubbleElement(element, index, chartOptions); styles += generateBubbleAnimationStyle(element, index); } - svg += ''; // Close bubbles group svg += svgLegend; + svg += ''; // Close content group svg += ``; svg += ''; diff --git a/src/chart/styles.ts b/src/chart/styles.ts index 27f0167..14299dc 100644 --- a/src/chart/styles.ts +++ b/src/chart/styles.ts @@ -6,15 +6,22 @@ import { StyleError } from '../errors/custom-errors.js'; export const defaultFontFamily = "-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans',Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'"; +export const chartPadding = 8; +export const chartBorderRadius = 10; + export function getCommonStyles(theme: ThemeBase): string { try { return ` svg { font-family: ${defaultFontFamily}; - background: ${theme.backgroundColor}; - border: ${theme.border}; - border-radius: ${theme.borderRadius}; - padding: ${theme.padding}; + } + .chart-background { + fill: ${theme.backgroundColor}; + width: 99%; + height: 99%; + x: 0.5; + y: 0.5; + rx: ${theme.border.rounded ? chartBorderRadius : 0}; } text { fill: ${theme.textColor}; diff --git a/src/chart/themes.ts b/src/chart/themes.ts index 23a0119..748ef9d 100644 --- a/src/chart/themes.ts +++ b/src/chart/themes.ts @@ -1,49 +1,61 @@ export abstract class ThemeBase { public abstract textColor: string; public abstract backgroundColor: string; - public abstract border: string; - public abstract borderRadius: string; - public abstract padding: string; + public abstract border: { + color: string; + width: number; + rounded: boolean; + }; } export class DefaultTheme extends ThemeBase { - public textColor = '#007acc'; + public textColor = '#777777'; public backgroundColor = 'transparent'; - public border = 'none'; - public borderRadius = '0'; - public padding = '0'; + public border = { + color: 'none', + width: 0, + rounded: false, + }; } export class LightTheme extends ThemeBase { public textColor = '#1f2328'; public backgroundColor = '#ffffff'; - public border = `1.5px solid ${this.textColor}77`; - public borderRadius = '.5rem'; - public padding = '.5rem'; + public border = { + color: `${this.textColor}77`, + width: 2, + rounded: true, + }; } export class DarkTheme extends ThemeBase { public textColor = '#f0f6fc'; public backgroundColor = '#0d1117'; - public border = `1.5px solid ${this.textColor}aa`; - public borderRadius = '.5rem'; - public padding = '.5rem'; + public border = { + color: `${this.textColor}aa`, + width: 2, + rounded: true, + }; } export class DarkHighContrastTheme extends ThemeBase { public textColor = '#ffffff'; public backgroundColor = '#010409'; - public border = `1.5px solid ${this.textColor}`; - public borderRadius = '.5rem'; - public padding = '.5rem'; + public border = { + color: this.textColor, + width: 1, + rounded: true, + }; } export class DarkDimmedTheme extends ThemeBase { public textColor = '#d1d7e0'; public backgroundColor = '#212830'; - public border = `1.5px solid ${this.textColor}55`; - public borderRadius = '.5rem'; - public padding = '.5rem'; + public border = { + color: `${this.textColor}55`, + width: 2, + rounded: true, + }; } export const themeMap: { [key: string]: ThemeBase } = { diff --git a/src/common/utils.ts b/src/common/utils.ts index 7dcffcb..292fc76 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -15,7 +15,7 @@ export function mapConfigToBubbleChartOptions( ): BubbleChartOptions { const theme = typeof config.theme === 'string' - ? themeMap[config.theme.toLowerCase()] + ? themeMap[config.theme.toLowerCase()] || themeMap.default : config.theme; return { width: config.width, @@ -43,3 +43,12 @@ export function truncateText(text: string, maxChars: number): string { } return text; } + +export function getPxValue(value: string): number { + if (!value || value === 'none') return 0; + const pxMatch = value.match(/(\d+(\.\d+)?)(px)/); + if (pxMatch) { + return parseFloat(pxMatch[1]); + } + return 0; +} diff --git a/tests/chart/styles.test.ts b/tests/chart/styles.test.ts index b5e28ad..23ce97f 100644 --- a/tests/chart/styles.test.ts +++ b/tests/chart/styles.test.ts @@ -4,6 +4,7 @@ import { getCommonStyles, generateBubbleAnimationStyle, getLegendItemAnimationStyle, + chartBorderRadius, } from '../../src/chart/styles'; import { LightTheme, ThemeBase } from '../../src/chart/themes'; import { BubbleData } from '../../src/chart/types/bubbleData'; @@ -12,11 +13,11 @@ describe('Styles Tests', () => { it('getCommonStyles generates correct styles', () => { const theme = new LightTheme(); const styles = getCommonStyles(theme); - expect(styles).toContain(`background: ${theme.backgroundColor}`); - expect(styles).toContain(`fill: ${theme.textColor}`); - expect(styles).toContain(`border-radius: ${theme.borderRadius}`); - expect(styles).toContain(`border: ${theme.border}`); - expect(styles).toContain(`padding: ${theme.padding}`); + expect(styles).toContain('.chart-background'); + expect(styles).toContain(`fill: ${theme.backgroundColor}`); + expect(styles).toContain( + `rx: ${theme.border.rounded ? chartBorderRadius : 0}`, + ); }); it('generateBubbleAnimationStyle generates correct animation styles', () => { diff --git a/tests/chart/themes.test.ts b/tests/chart/themes.test.ts index 4f33bf8..dc64e18 100644 --- a/tests/chart/themes.test.ts +++ b/tests/chart/themes.test.ts @@ -13,22 +13,23 @@ describe('Themes', () => { const theme = themeMap.default; it('should have correct properties', () => { - expect(theme.textColor).toBe('#007acc'); + expect(theme.textColor).toBe('#777777'); expect(theme.backgroundColor).toBe('transparent'); - expect(theme.border).toBe('none'); - expect(theme.borderRadius).toBe('0'); - expect(theme.padding).toBe('0'); + expect(theme.border.color).toBe('none'); + expect(theme.border.width).toBe(0); + expect(theme.border.rounded).toBe(false); }); }); + describe('LightTheme', () => { const theme = new LightTheme(); it('should have correct basic properties', () => { expect(theme.textColor).toBe('#1f2328'); expect(theme.backgroundColor).toBe('#ffffff'); - expect(theme.border).toBe(`1.5px solid ${theme.textColor}77`); - expect(theme.borderRadius).toBe('.5rem'); - expect(theme.padding).toBe('.5rem'); + expect(theme.border.color).toBe(`${theme.textColor}77`); + expect(theme.border.width).toBe(2); + expect(theme.border.rounded).toBe(true); }); }); @@ -38,9 +39,9 @@ describe('Themes', () => { it('should have correct basic properties', () => { expect(theme.textColor).toBe('#f0f6fc'); expect(theme.backgroundColor).toBe('#0d1117'); - expect(theme.border).toBe(`1.5px solid ${theme.textColor}aa`); - expect(theme.borderRadius).toBe('.5rem'); - expect(theme.padding).toBe('.5rem'); + expect(theme.border.color).toBe(`${theme.textColor}aa`); + expect(theme.border.width).toBe(2); + expect(theme.border.rounded).toBe(true); }); }); @@ -50,9 +51,9 @@ describe('Themes', () => { it('should have correct basic properties', () => { expect(theme.textColor).toBe('#ffffff'); expect(theme.backgroundColor).toBe('#010409'); - expect(theme.border).toBe(`1.5px solid ${theme.textColor}`); - expect(theme.borderRadius).toBe('.5rem'); - expect(theme.padding).toBe('.5rem'); + expect(theme.border.color).toBe(theme.textColor); + expect(theme.border.width).toBe(1); + expect(theme.border.rounded).toBe(true); }); }); @@ -62,9 +63,9 @@ describe('Themes', () => { it('should have correct basic properties', () => { expect(theme.textColor).toBe('#d1d7e0'); expect(theme.backgroundColor).toBe('#212830'); - expect(theme.border).toBe(`1.5px solid ${theme.textColor}55`); - expect(theme.borderRadius).toBe('.5rem'); - expect(theme.padding).toBe('.5rem'); + expect(theme.border.color).toBe(`${theme.textColor}55`); + expect(theme.border.width).toBe(2); + expect(theme.border.rounded).toBe(true); }); }); diff --git a/tests/common/utils.test.ts b/tests/common/utils.test.ts index 1e7cea0..aed24be 100644 --- a/tests/common/utils.test.ts +++ b/tests/common/utils.test.ts @@ -3,6 +3,7 @@ import { themeMap } from '../../src/chart/themes'; import { BubbleChartOptions } from '../../src/chart/types/chartOptions'; import { CustomConfigOptions } from '../../src/chart/types/config'; import { + getPxValue, isDevEnvironment, isProdEnvironment, mapConfigToBubbleChartOptions, @@ -59,6 +60,93 @@ describe('Utils Tests', () => { expect(mapConfigToBubbleChartOptions(config)).toEqual(expectedOptions); }); + it('mapConfigToBubbleChartOptions should handle custom theme object', () => { + const customTheme = { + textColor: '#123456', + backgroundColor: '#654321', + border: { + color: '#abcdef', + width: 1, + rounded: true, + }, + }; + const config: CustomConfigOptions = { + width: 600, + height: 400, + displayValues: 'all', + title: { + text: 'Test Chart', + fontSize: '16px', + fontWeight: 'bold', + color: '#000000', + align: 'middle', + }, + legend: { + show: true, + align: 'right', + }, + theme: customTheme, + }; + const expectedOptions: BubbleChartOptions = { + width: 600, + height: 400, + displayValues: 'all', + usePercentages: false, + titleOptions: { + text: 'Test Chart', + fontSize: '16px', + fontWeight: 'bold', + fill: '#000000', + textAnchor: 'middle', + }, + legendOptions: { + show: true, + align: 'right', + }, + theme: customTheme, + }; + expect(mapConfigToBubbleChartOptions(config)).toEqual(expectedOptions); + }); + + it('mapConfigToBubbleChartOptions should use default theme if name is not found in map', () => { + const config: CustomConfigOptions = { + width: 600, + height: 400, + displayValues: 'all', + title: { + text: 'Test Chart', + fontSize: '16px', + fontWeight: 'bold', + color: '#000000', + align: 'middle', + }, + legend: { + show: true, + align: 'right', + }, + theme: 'nonexistent_theme', + }; + const expectedOptions: BubbleChartOptions = { + width: 600, + height: 400, + displayValues: 'all', + usePercentages: false, + titleOptions: { + text: 'Test Chart', + fontSize: '16px', + fontWeight: 'bold', + fill: '#000000', + textAnchor: 'middle', + }, + legendOptions: { + show: true, + align: 'right', + }, + theme: themeMap.default, + }; + expect(mapConfigToBubbleChartOptions(config)).toEqual(expectedOptions); + }); + it('truncateText should truncate text correctly', () => { const text = 'This is a long text that needs to be truncated'; const truncatedText = truncateText(text, 10); @@ -70,4 +158,31 @@ describe('Utils Tests', () => { const truncatedText = truncateText(text, 20); expect(truncatedText).toBe('Short text'); }); + + describe('getPxValue', () => { + it('should return a numeric value for a "px" string', () => { + expect(getPxValue('15px')).toBe(15); + }); + + it('should return 0 for an empty string or "none"', () => { + expect(getPxValue('')).toBe(0); + expect(getPxValue('none')).toBe(0); + }); + + it('should return 0 for unsupported or non-"px" unit types', () => { + expect(getPxValue('2rem')).toBe(0); + expect(getPxValue('3em')).toBe(0); + expect(getPxValue('10pt')).toBe(0); + expect(getPxValue('100%')).toBe(0); + expect(getPxValue('10abc')).toBe(0); + }); + + it('should trim whitespace before processing', () => { + expect(getPxValue(' 20px ')).toBe(20); + }); + + it('should extract numeric value from a border style', () => { + expect(getPxValue('3px solid red')).toBe(3); + }); + }); }); From 85eac6b81b0ce838d9c6064ec90865cd1b7e3ea2 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 22 Feb 2025 13:22:47 +0100 Subject: [PATCH 02/12] Update @octokit/rest and related dependencies to latest versions --- package.json | 2 +- yarn.lock | 99 +++++++++++++++++++++------------------------------- 2 files changed, 40 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index 5d3cb68..9c70478 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "yaml": "^2.7.0" }, "dependencies": { - "@octokit/rest": "^21.1.0", + "@octokit/rest": "^21.1.1", "d3": "^7.9.0", "dotenv": "^16.4.5", "express": "^4.19.2", diff --git a/yarn.lock b/yarn.lock index 959af43..cb020f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -471,15 +471,15 @@ resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz" integrity sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA== -"@octokit/core@^6.1.3": - version "6.1.3" - resolved "https://registry.npmjs.org/@octokit/core/-/core-6.1.3.tgz" - integrity sha512-z+j7DixNnfpdToYsOutStDgeRzJSMnbj8T1C/oQjB6Aa+kRfNjs/Fn7W6c8bmlt6mfy3FkgeKBRnDjxQow5dow== +"@octokit/core@^6.1.4": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-6.1.4.tgz#f5ccf911cc95b1ce9daf6de425d1664392f867db" + integrity sha512-lAS9k7d6I0MPN+gb9bKDt7X8SdxknYqAMh44S5L+lNqIN2NuV8nvv3g8rPp7MuRxcOpxpUIATWprO0C34a8Qmg== dependencies: "@octokit/auth-token" "^5.0.0" "@octokit/graphql" "^8.1.2" - "@octokit/request" "^9.1.4" - "@octokit/request-error" "^6.1.6" + "@octokit/request" "^9.2.1" + "@octokit/request-error" "^6.1.7" "@octokit/types" "^13.6.2" before-after-hook "^3.0.2" universal-user-agent "^7.0.0" @@ -492,6 +492,14 @@ "@octokit/types" "^13.0.0" universal-user-agent "^7.0.2" +"@octokit/endpoint@^10.1.3": + version "10.1.3" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-10.1.3.tgz#bfe8ff2ec213eb4216065e77654bfbba0fc6d4de" + integrity sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA== + dependencies: + "@octokit/types" "^13.6.2" + universal-user-agent "^7.0.2" + "@octokit/graphql@^8.1.2": version "8.1.2" resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.2.tgz" @@ -511,10 +519,10 @@ resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz" integrity sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g== -"@octokit/plugin-paginate-rest@^11.4.0": - version "11.4.0" - resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.0.tgz" - integrity sha512-ttpGck5AYWkwMkMazNCZMqxKqIq1fJBNxBfsFwwfyYKTf914jKkLF0POMS3YkPBwp5g1c2Y4L79gDz01GhSr1g== +"@octokit/plugin-paginate-rest@^11.4.2": + version "11.4.2" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.2.tgz#8f46a1de74c35e016c86701ef4ea0e8ef25a06e0" + integrity sha512-BXJ7XPCTDXFF+wxcg/zscfgw2O/iDPtNSkwwR1W1W5c4Mb3zav/M2XvxQ23nVmKj7jpweB4g8viMeCQdm7LMVA== dependencies: "@octokit/types" "^13.7.0" @@ -537,10 +545,10 @@ dependencies: "@octokit/types" "^13.0.0" -"@octokit/request-error@^6.1.6": - version "6.1.6" - resolved "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.6.tgz" - integrity sha512-pqnVKYo/at0NuOjinrgcQYpEbv4snvP3bKMRqHaD9kIsk9u1LCpb2smHZi8/qJfgeNqLo5hNW4Z7FezNdEo0xg== +"@octokit/request-error@^6.1.7": + version "6.1.7" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-6.1.7.tgz#44fc598f5cdf4593e0e58b5155fe2e77230ff6da" + integrity sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g== dependencies: "@octokit/types" "^13.6.2" @@ -555,13 +563,24 @@ fast-content-type-parse "^2.0.0" universal-user-agent "^7.0.2" -"@octokit/rest@^21.1.0": - version "21.1.0" - resolved "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.0.tgz" - integrity sha512-93iLxcKDJboUpmnUyeJ6cRIi7z7cqTZT1K7kRK4LobGxwTwpsa+2tQQbRQNGy7IFDEAmrtkf4F4wBj3D5rVlJQ== +"@octokit/request@^9.2.1": + version "9.2.2" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-9.2.2.tgz#754452ec4692d7fdc32438a14e028eba0e6b2c09" + integrity sha512-dZl0ZHx6gOQGcffgm1/Sf6JfEpmh34v3Af2Uci02vzUYz6qEN6zepoRtmybWXIGXFIK8K9ylE3b+duCWqhArtg== + dependencies: + "@octokit/endpoint" "^10.1.3" + "@octokit/request-error" "^6.1.7" + "@octokit/types" "^13.6.2" + fast-content-type-parse "^2.0.0" + universal-user-agent "^7.0.2" + +"@octokit/rest@^21.1.1": + version "21.1.1" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-21.1.1.tgz#7a70455ca451b1d253e5b706f35178ceefb74de2" + integrity sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg== dependencies: - "@octokit/core" "^6.1.3" - "@octokit/plugin-paginate-rest" "^11.4.0" + "@octokit/core" "^6.1.4" + "@octokit/plugin-paginate-rest" "^11.4.2" "@octokit/plugin-request-log" "^5.3.1" "@octokit/plugin-rest-endpoint-methods" "^13.3.0" @@ -1108,14 +1127,6 @@ "@typescript-eslint/visitor-keys" "8.24.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@8.22.0": - version "8.22.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.22.0.tgz#e85836ddeb8eae715f870628bcc32fe96aaf4d0e" - integrity sha512-/lwVV0UYgkj7wPSw0o8URy6YI64QmcOdwHuGuxWIYznO6d45ER0wXUbksr9pYdViAofpUCNJx/tAzNukgvaaiQ== - dependencies: - "@typescript-eslint/types" "8.22.0" - "@typescript-eslint/visitor-keys" "8.22.0" - "@typescript-eslint/scope-manager@8.24.0": version "8.24.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.24.0.tgz#2e34b3eb2ce768f2ffb109474174ced5417002b1" @@ -1134,30 +1145,11 @@ debug "^4.3.4" ts-api-utils "^2.0.1" -"@typescript-eslint/types@8.22.0": - version "8.22.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.22.0.tgz#d9dec7116479ad03aeb6c8ac9c5223c4c79cf360" - integrity sha512-0S4M4baNzp612zwpD4YOieP3VowOARgK2EkN/GBn95hpyF8E2fbMT55sRHWBq+Huaqk3b3XK+rxxlM8sPgGM6A== - "@typescript-eslint/types@8.24.0": version "8.24.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.24.0.tgz#694e7fb18d70506c317b816de9521300b0f72c8e" integrity sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw== -"@typescript-eslint/typescript-estree@8.22.0": - version "8.22.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.22.0.tgz#c188c3e19529d5b3145577c0bd967e2683b114df" - integrity sha512-SJX99NAS2ugGOzpyhMza/tX+zDwjvwAtQFLsBo3GQxiGcvaKlqGBkmZ+Y1IdiSi9h4Q0Lr5ey+Cp9CGWNY/F/w== - dependencies: - "@typescript-eslint/types" "8.22.0" - "@typescript-eslint/visitor-keys" "8.22.0" - debug "^4.3.4" - fast-glob "^3.3.2" - is-glob "^4.0.3" - minimatch "^9.0.4" - semver "^7.6.0" - ts-api-utils "^2.0.0" - "@typescript-eslint/typescript-estree@8.24.0": version "8.24.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.0.tgz#0487349be174097bb329a58273100a9629e03c6c" @@ -1182,14 +1174,6 @@ "@typescript-eslint/types" "8.24.0" "@typescript-eslint/typescript-estree" "8.24.0" -"@typescript-eslint/visitor-keys@8.22.0": - version "8.22.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.22.0.tgz#02cc005014c372033eb9171e2275b76cba722a3f" - integrity sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w== - dependencies: - "@typescript-eslint/types" "8.22.0" - eslint-visitor-keys "^4.2.0" - "@typescript-eslint/visitor-keys@8.24.0": version "8.24.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.0.tgz#36ecf0b9b1d819ad88a3bd4157ab7d594cb797c9" @@ -4468,11 +4452,6 @@ tr46@~0.0.3: resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== -ts-api-utils@^2.0.0, ts-api-utils@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.0.1.tgz#660729385b625b939aaa58054f45c058f33f10cd" - integrity sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w== - ts-api-utils@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.0.1.tgz#660729385b625b939aaa58054f45c058f33f10cd" From c7e3930a6c1545c449706235b4342610bc0faefa Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sun, 23 Feb 2025 00:56:13 +0100 Subject: [PATCH 03/12] Bubble chart generator refactoring and enhance styling features --- src/chart/components/bubble.ts | 76 +++++++++ src/chart/components/legend.ts | 95 ++++++++++++ src/chart/components/title.ts | 81 ++++++++++ src/chart/generator.ts | 275 +++------------------------------ src/chart/styles.ts | 8 +- tests/chart/generator.test.ts | 2 + 6 files changed, 278 insertions(+), 259 deletions(-) create mode 100644 src/chart/components/bubble.ts create mode 100644 src/chart/components/legend.ts create mode 100644 src/chart/components/title.ts diff --git a/src/chart/components/bubble.ts b/src/chart/components/bubble.ts new file mode 100644 index 0000000..4dbf15b --- /dev/null +++ b/src/chart/components/bubble.ts @@ -0,0 +1,76 @@ +import { HierarchyCircularNode } from 'd3'; +import { GeneratorError } from '../../errors/custom-errors.js'; +import { BubbleData } from '../types/bubbleData.js'; +import { BubbleChartOptions } from '../types/chartOptions.js'; +import { getColor, getName, wrapText, measureTextHeight } from '../utils.js'; + +export async function createBubbleElement( + node: HierarchyCircularNode, + index: number, + chartOptions: BubbleChartOptions, +): Promise { + try { + const color = getColor(node.data); + const radius = node.r; + const iconUrl = node.data.icon as string; + const language = getName(node.data); + const value = chartOptions.usePercentages + ? `${node.data.value}%` + : node.data.value; + + // Main group for the bubble + let bubble = ``; + + // Ellipses for 3D effect + bubble += ` + + + `; + + // Circle base + bubble += ` + + + `; + + // Icon or text inside the bubble + if (iconUrl) { + bubble += ``; + } else { + const fontSize = radius / 3 + 'px'; + const textLines = await wrapText(language, radius * 2, fontSize); + + let displayedText = ''; + if (textLines.length > 1) { + const lineHeight = await measureTextHeight(language, fontSize); + const adjustPos = radius / 5; + textLines.forEach((line, i) => { + displayedText += ` + ${line} + `; + }); + } else { + displayedText = language; + } + + bubble += `${displayedText}`; + } + + // Value text + if ( + chartOptions.displayValues === 'all' || + chartOptions.displayValues === 'bubbles' + ) { + bubble += `${value}`; + } + + bubble += ''; // Close the bubble group + + return bubble; + } catch (error) { + throw new GeneratorError( + 'Failed to create bubble element.', + error instanceof Error ? error : undefined, + ); + } +} diff --git a/src/chart/components/legend.ts b/src/chart/components/legend.ts new file mode 100644 index 0000000..305be1b --- /dev/null +++ b/src/chart/components/legend.ts @@ -0,0 +1,95 @@ +import { GeneratorError } from '../../errors/custom-errors.js'; +import { chartPadding, legendTextSize } from '../styles.js'; +import { BubbleData } from '../types/bubbleData.js'; +import { BubbleChartOptions } from '../types/chartOptions.js'; +import { measureTextWidth } from '../utils.js'; + +export async function createLegend( + data: BubbleData[], + svgWidth: number, + maxBubbleY: number, + distanceFromBubbleChart: number, + chartOptions: BubbleChartOptions, +): Promise<{ svgLegend: string; legendHeight: number }> { + try { + const legendMarginTop = distanceFromBubbleChart; // Distance from the last bubble to the legend + const legendItemHeight = 20; // Height for each legend row + const legendYPadding = 10; // Vertical padding between rows + const legendXPadding = 50; // Horizontal spacing between legend items + + const legendY = maxBubbleY + legendMarginTop; // Start position for the legend + let svgLegend = ``; + + // Prepare legend items with their measured widths + const legendItems = data.map(async (item) => { + const value = + chartOptions.displayValues === 'all' || + chartOptions.displayValues === 'legend' + ? chartOptions.usePercentages + ? ` (${item.value}%)` + : ` (${item.value})` + : ''; + const text = `${item.name}${value}`; + const textWidth = await measureTextWidth(text, legendTextSize); + return { + text, + width: textWidth + legendXPadding, // Include circle and padding + color: item.color, + }; + }); + + const rowItems: { text: string; width: number; color: string }[][] = [[]]; // Array of rows, each row contains legend items + let currentRowWidth = 0; + let currentRowIndex = 0; + + // Group legend items into rows based on svgWidth + for await (const i of legendItems) { + if (currentRowWidth + i.width > svgWidth) { + currentRowIndex++; + rowItems[currentRowIndex] = []; + currentRowWidth = 0; + } + rowItems[currentRowIndex].push(i); + currentRowWidth += i.width; + } + + // Generate SVG for legend rows + let rowY = 0; + rowItems.forEach((row, rowIndex) => { + const rowWidth = row.reduce((sum, item) => sum + item.width, 0); + let rowX = 0; + + if (chartOptions.legendOptions.align === 'center') { + rowX = (svgWidth - rowWidth) / 2; + } else if (chartOptions.legendOptions.align === 'right') { + rowX = svgWidth - rowWidth + chartPadding; + } + + let animationDelay = rowIndex; + row.forEach((item, itemIndex) => { + animationDelay += itemIndex * 0.1; + svgLegend += ` + + + ${item.text} + + `; + rowX += item.width; // Next item + }); + rowY += legendItemHeight + legendYPadding; // Next row + }); + + svgLegend += ''; + + // Calculate the total height of the legend element + const legendHeight = + legendY - maxBubbleY - legendMarginTop + rowY + chartPadding; + + return { svgLegend: svgLegend, legendHeight }; + } catch (error) { + throw new GeneratorError( + 'Failed to create legend.', + error instanceof Error ? error : undefined, + ); + } +} diff --git a/src/chart/components/title.ts b/src/chart/components/title.ts new file mode 100644 index 0000000..009572a --- /dev/null +++ b/src/chart/components/title.ts @@ -0,0 +1,81 @@ +import { truncateText } from '../../common/utils.js'; +import { GeneratorError } from '../../errors/custom-errors.js'; +import { chartPadding } from '../styles.js'; +import { TitleOptions } from '../types/chartOptions.js'; +import { + measureTextWidth, + wrapText, + escapeSpecialChars, + parseEmojis, + toKebabCase, + getAlignmentPosition, +} from '../utils.js'; + +export async function createTitleElement( + titleOptions: TitleOptions, + width: number, + titleHeight: number, +): Promise<{ svgTitle: string; titleLines: number }> { + try { + const style = Object.keys(titleOptions) + .filter( + (style) => + style !== 'text' && + style !== 'textAnchor' && + titleOptions[style] !== null, + ) + .map((style) => `${toKebabCase(style)}: ${titleOptions[style]};`) + .join(' '); + + const titleAlign = getAlignmentPosition(titleOptions.textAnchor, width); + + titleOptions.text = escapeSpecialChars(parseEmojis(titleOptions.text)); + const textWidth = await measureTextWidth( + titleOptions.text, + titleOptions.fontSize, + titleOptions.fontWeight, + ); + + let textElement = ''; + let lines: string[] | null = null; + if (textWidth > width) { + lines = await wrapText( + titleOptions.text, + width, + titleOptions.fontSize, + titleOptions.fontWeight, + ); + + if (lines.length > 3) { + lines = lines.slice(0, 3); + lines[2] = truncateText(lines[2], lines[2].length - 3); + } + + lines.forEach((line, index) => { + textElement += ` + ${line} + `; + }); + } else { + textElement = titleOptions.text; + } + + return { + svgTitle: ` + + ${textElement} + + `, + titleLines: lines?.length || 1, + }; + } catch (error) { + throw new GeneratorError( + 'Failed to create title element.', + error instanceof Error ? error : undefined, + ); + } +} diff --git a/src/chart/generator.ts b/src/chart/generator.ts index bb57261..635d23b 100644 --- a/src/chart/generator.ts +++ b/src/chart/generator.ts @@ -1,4 +1,6 @@ -import { hierarchy, HierarchyCircularNode, max, pack } from 'd3'; +import { hierarchy, max, pack } from 'd3'; +import { createBubbleElement } from './components/bubble.js'; +import { createLegend } from './components/legend.js'; import { createSVGDefs } from './defs.js'; import { getCommonStyles, @@ -7,252 +9,11 @@ import { chartPadding, } from './styles.js'; import { BubbleData } from './types/bubbleData.js'; -import { BubbleChartOptions, TitleOptions } from './types/chartOptions.js'; -import { - getColor, - getName, - measureTextHeight, - measureTextWidth, - parseEmojis, - toKebabCase, - wrapText, - getAlignmentPosition, - escapeSpecialChars, -} from './utils.js'; -import { truncateText } from '../common/utils.js'; +import { BubbleChartOptions } from './types/chartOptions.js'; +import { escapeSpecialChars, measureTextHeight } from './utils.js'; import { GeneratorError } from '../errors/custom-errors.js'; +import { createTitleElement } from './components/title.js'; -async function createTitleElement( - titleOptions: TitleOptions, - width: number, - titleHeight: number, -): Promise<{ svgTitle: string; titleLines: number }> { - try { - const style = Object.keys(titleOptions) - .filter( - (style) => - style !== 'text' && - style !== 'textAnchor' && - titleOptions[style] !== null, - ) - .map((style) => `${toKebabCase(style)}: ${titleOptions[style]};`) - .join(' '); - - const titleAlign = getAlignmentPosition(titleOptions.textAnchor, width); - - titleOptions.text = escapeSpecialChars(parseEmojis(titleOptions.text)); - const textWidth = await measureTextWidth( - titleOptions.text, - titleOptions.fontSize, - titleOptions.fontWeight, - ); - - let textElement = ''; - let lines: string[] | null = null; - if (textWidth > width) { - lines = await wrapText( - titleOptions.text, - width, - titleOptions.fontSize, - titleOptions.fontWeight, - ); - const linePadding = 0; // Padding between lines - - if (lines.length > 3) { - lines = lines.slice(0, 3); - lines[2] = truncateText(lines[2], lines[2].length - 3); - } - - lines.forEach((line, index) => { - textElement += ` - ${line} - `; - }); - } else { - textElement = titleOptions.text; - } - - return { - svgTitle: ` - - ${textElement} - - `, - titleLines: lines?.length || 1, - }; - } catch (error) { - throw new GeneratorError( - 'Failed to create title element.', - error instanceof Error ? error : undefined, - ); - } -} - -async function createBubbleElement( - node: HierarchyCircularNode, - index: number, - chartOptions: BubbleChartOptions, -): Promise { - try { - const color = getColor(node.data); - const radius = node.r; - const iconUrl = node.data.icon as string; - const language = getName(node.data); - const value = chartOptions.usePercentages - ? `${node.data.value}%` - : node.data.value; - - // Main group for the bubble - let bubble = ``; - - // Ellipses for 3D effect - bubble += ` - - - `; - - // Circle base - bubble += ` - - - `; - - // Icon or text inside the bubble - if (iconUrl) { - bubble += ``; - } else { - const fontSize = radius / 3 + 'px'; - const textLines = await wrapText(language, radius * 2, fontSize); - - let displayedText = ''; - if (textLines.length > 1) { - const lineHeight = await measureTextHeight(language, fontSize); - const adjustPos = radius / 5; - textLines.forEach((line, i) => { - displayedText += ` - ${line} - `; - }); - } else { - displayedText = language; - } - - bubble += `${displayedText}`; - } - - // Value text - if ( - chartOptions.displayValues === 'all' || - chartOptions.displayValues === 'bubbles' - ) { - bubble += `${value}`; - } - - bubble += ''; // Close the bubble group - - return bubble; - } catch (error) { - throw new GeneratorError( - 'Failed to create bubble element.', - error instanceof Error ? error : undefined, - ); - } -} - -async function createLegend( - data: BubbleData[], - svgWidth: number, - svgMaxY: number, - distanceFromBubbleChart: number, - chartOptions: BubbleChartOptions, -): Promise<{ svgLegend: string; legendHeight: number }> { - try { - const legendMarginTop = distanceFromBubbleChart; // Distance from the last bubble to the legend - const legendItemHeight = 20; // Height for each legend row - const legendYPadding = 10; // Vertical padding between rows - const legendXPadding = 50; // Horizontal spacing between legend items - - let legendY = svgMaxY + legendMarginTop; // Start position for the legend - let svgLegend = ``; - - // Prepare legend items with their measured widths - const legendItems = data.map(async (item) => { - const value = - chartOptions.displayValues === 'all' || - chartOptions.displayValues === 'legend' - ? chartOptions.usePercentages - ? ` (${item.value}%)` - : ` (${item.value})` - : ''; - const text = `${item.name}${value}`; - const textWidth = await measureTextWidth(text, '12px'); - return { - text, - width: textWidth + legendXPadding, // Include circle and padding - color: item.color, - }; - }); - - const rowItems: { text: string; width: number; color: string }[][] = [[]]; // Array of rows, each row contains legend items - let currentRowWidth = 0; - let currentRowIndex = 0; - - // Group legend items into rows based on svgWidth - for await (const i of legendItems) { - if (currentRowWidth + i.width > svgWidth) { - currentRowIndex++; - rowItems[currentRowIndex] = []; - currentRowWidth = 0; - } - rowItems[currentRowIndex].push(i); - currentRowWidth += i.width; - } - - // Generate SVG for legend rows - rowItems.forEach((row, rowIndex) => { - const rowWidth = row.reduce((sum, item) => sum + item.width, 0); - let rowX = 0; - - if (chartOptions.legendOptions.align === 'center') { - rowX = (svgWidth - rowWidth) / 2; - } else if (chartOptions.legendOptions.align === 'right') { - rowX = svgWidth - rowWidth; - } - - row.forEach((item, itemIndex) => { - const animationDelay = (rowIndex * row.length + itemIndex) * 0.1; - svgLegend += ` - - - ${item.text} - - `; - rowX += item.width; // Next item - }); - legendY += legendItemHeight + legendYPadding; // Next row - }); - - svgLegend += ''; - - // Calculate the total height of the legend element - const legendHeight = legendY - svgMaxY - legendMarginTop + legendYPadding; - - return { svgLegend: svgLegend, legendHeight }; - } catch (error) { - throw new GeneratorError( - 'Failed to create legend.', - error instanceof Error ? error : undefined, - ); - } -} - -/** - * Create the SVG element for the bubble chart. - */ export async function createBubbleChart( data: BubbleData[], chartOptions: BubbleChartOptions, @@ -275,8 +36,9 @@ export async function createBubbleChart( item.name = escapeSpecialChars(item.name); }); - const width = chartOptions.width; - const height = chartOptions.height; + const borderWidth = chartOptions.theme?.border?.width || 0; + const width = chartOptions.width + borderWidth * 2 + chartPadding * 2; + const height = chartOptions.height + borderWidth * 2 + chartPadding * 2; const bubblesPack = pack().size([width, height]).padding(1.5); const root = hierarchy({ @@ -304,9 +66,11 @@ export async function createBubbleChart( // Calculate full height const bubbleChartMargin = 20; // Space between bubbles and title/legend - const maxY = max(bubbleNodes, (d) => d.y + d.r + bubbleChartMargin) || height; - const distanceFromBubbleChart = titleHeight * titleLines + bubbleChartMargin; - let fullHeight = maxY + distanceFromBubbleChart; + const maxBubbleY = + max(bubbleNodes, (d) => d.y + d.r + bubbleChartMargin) || height; + const distanceFromBubbleChart = + titleHeight * titleLines + bubbleChartMargin + chartPadding; + let fullHeight = maxBubbleY + distanceFromBubbleChart; // Common styles let styles = getCommonStyles(chartOptions.theme); @@ -320,7 +84,7 @@ export async function createBubbleChart( const legendResult = await createLegend( data, width, - maxY, + maxBubbleY, distanceFromBubbleChart, chartOptions, ); @@ -330,12 +94,11 @@ export async function createBubbleChart( } // Start building the SVG - const borderPx = chartOptions.theme?.border?.width || 0; const borderColor = chartOptions.theme?.border?.color || 'transparent'; - let svg = ``; + let svg = ``; svg += createSVGDefs(); - svg += ``; - svg += ``; + svg += ``; + // svg += ``; svg += svgTitle; svg += ``; for await (const [index, element] of bubbleNodes.entries()) { @@ -344,7 +107,7 @@ export async function createBubbleChart( } svg += ''; // Close bubbles group svg += svgLegend; - svg += ''; // Close content group + // svg += ''; // Close content group svg += ``; svg += ''; diff --git a/src/chart/styles.ts b/src/chart/styles.ts index 14299dc..13e770f 100644 --- a/src/chart/styles.ts +++ b/src/chart/styles.ts @@ -8,19 +8,21 @@ export const defaultFontFamily = export const chartPadding = 8; export const chartBorderRadius = 10; +export const legendTextSize = '13px'; export function getCommonStyles(theme: ThemeBase): string { try { return ` svg { font-family: ${defaultFontFamily}; + background: black; } .chart-background { fill: ${theme.backgroundColor}; width: 99%; height: 99%; - x: 0.5; - y: 0.5; + x: 0.5%; + y: 0.5%; rx: ${theme.border.rounded ? chartBorderRadius : 0}; } text { @@ -106,7 +108,7 @@ export function getLegendItemAnimationStyle(): string { animation: fadeIn 0.3s forwards; } .legend-item text { - font-size: 12px; + font-size: ${legendTextSize}; text-anchor: start; dominant-baseline: central; } diff --git a/tests/chart/generator.test.ts b/tests/chart/generator.test.ts index 6bcf381..283786e 100644 --- a/tests/chart/generator.test.ts +++ b/tests/chart/generator.test.ts @@ -13,6 +13,8 @@ import { } from '../../src/chart/types/chartOptions'; import { GeneratorError, StyleError } from '../../src/errors/custom-errors'; +// TODO: create components tests? + describe('Generator', () => { describe('createBubbleChart', () => { it('should return null if no data is provided', async () => { From cdc2eade33206e20cf3a8b70f7306643a0e0206e Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sun, 23 Feb 2025 17:28:07 +0100 Subject: [PATCH 04/12] Refactor bubble chart component structure and enhance bubble element generation --- src/chart/components/bubble.ts | 76 --------------- src/chart/components/bubbles.ts | 108 +++++++++++++++++++++ src/chart/components/legend.ts | 146 ++++++++++++++++++----------- src/chart/components/title.ts | 124 ++++++++++++++++--------- src/chart/generator.ts | 160 +++++++++++++++++++++++++------- tests/chart/generator.test.ts | 2 +- 6 files changed, 405 insertions(+), 211 deletions(-) delete mode 100644 src/chart/components/bubble.ts create mode 100644 src/chart/components/bubbles.ts diff --git a/src/chart/components/bubble.ts b/src/chart/components/bubble.ts deleted file mode 100644 index 4dbf15b..0000000 --- a/src/chart/components/bubble.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { HierarchyCircularNode } from 'd3'; -import { GeneratorError } from '../../errors/custom-errors.js'; -import { BubbleData } from '../types/bubbleData.js'; -import { BubbleChartOptions } from '../types/chartOptions.js'; -import { getColor, getName, wrapText, measureTextHeight } from '../utils.js'; - -export async function createBubbleElement( - node: HierarchyCircularNode, - index: number, - chartOptions: BubbleChartOptions, -): Promise { - try { - const color = getColor(node.data); - const radius = node.r; - const iconUrl = node.data.icon as string; - const language = getName(node.data); - const value = chartOptions.usePercentages - ? `${node.data.value}%` - : node.data.value; - - // Main group for the bubble - let bubble = ``; - - // Ellipses for 3D effect - bubble += ` - - - `; - - // Circle base - bubble += ` - - - `; - - // Icon or text inside the bubble - if (iconUrl) { - bubble += ``; - } else { - const fontSize = radius / 3 + 'px'; - const textLines = await wrapText(language, radius * 2, fontSize); - - let displayedText = ''; - if (textLines.length > 1) { - const lineHeight = await measureTextHeight(language, fontSize); - const adjustPos = radius / 5; - textLines.forEach((line, i) => { - displayedText += ` - ${line} - `; - }); - } else { - displayedText = language; - } - - bubble += `${displayedText}`; - } - - // Value text - if ( - chartOptions.displayValues === 'all' || - chartOptions.displayValues === 'bubbles' - ) { - bubble += `${value}`; - } - - bubble += ''; // Close the bubble group - - return bubble; - } catch (error) { - throw new GeneratorError( - 'Failed to create bubble element.', - error instanceof Error ? error : undefined, - ); - } -} diff --git a/src/chart/components/bubbles.ts b/src/chart/components/bubbles.ts new file mode 100644 index 0000000..9086760 --- /dev/null +++ b/src/chart/components/bubbles.ts @@ -0,0 +1,108 @@ +import { HierarchyCircularNode } from 'd3'; +import { GeneratorError } from '../../errors/custom-errors.js'; +import { generateBubbleAnimationStyle } from '../styles.js'; +import { BubbleData } from '../types/bubbleData.js'; +import { BubbleChartOptions } from '../types/chartOptions.js'; +import { getColor, getName, wrapText, measureTextHeight } from '../utils.js'; + +export async function processBubbleNodes( + bubbleNodes: HierarchyCircularNode[], + chartOptions: BubbleChartOptions, +): Promise<{ bubbleElements: string; bubbleStyles: string }> { + let bubbleElements = ''; + let bubbleStyles = ''; + for (const [index, element] of bubbleNodes.entries()) { + bubbleElements += await createBubbleElement(element, index, chartOptions); + bubbleStyles += generateBubbleAnimationStyle(element, index); + } + return { bubbleElements, bubbleStyles }; +} + +async function createBubbleElement( + node: HierarchyCircularNode, + index: number, + chartOptions: BubbleChartOptions, +): Promise { + try { + const color = getColor(node.data); + const radius = node.r; + const iconUrl = node.data.icon as string; + const language = getName(node.data); + const value = chartOptions.usePercentages + ? `${node.data.value}%` + : node.data.value; + + let bubble = ``; + + bubble += generateEllipses(radius); + bubble += generateCircleBase(radius, color); + bubble += await generateIconOrText(iconUrl, language, radius, color); + bubble += generateValueText(chartOptions, value, radius); + + bubble += ''; // Close the bubble group + + return bubble; + } catch (error) { + throw new GeneratorError( + 'Failed to create bubble element.', + error instanceof Error ? error : undefined, + ); + } +} + +function generateEllipses(radius: number): string { + return ` + + + `; +} + +function generateCircleBase(radius: number, color: string): string { + return ` + + + `; +} + +async function generateIconOrText( + iconUrl: string, + language: string, + radius: number, + color: string, +): Promise { + if (iconUrl) { + return ``; + } else { + const fontSize = radius / 3 + 'px'; + const textLines = await wrapText(language, radius * 2, fontSize); + + let displayedText = ''; + if (textLines.length > 1) { + const lineHeight = await measureTextHeight(language, fontSize); + const adjustPos = radius / 5; + textLines.forEach((line, i) => { + displayedText += ` + ${line} + `; + }); + } else { + displayedText = language; + } + + return `${displayedText}`; + } +} + +function generateValueText( + chartOptions: BubbleChartOptions, + value: string | number, + radius: number, +): string { + if ( + chartOptions.displayValues === 'all' || + chartOptions.displayValues === 'bubbles' + ) { + return `${value}`; + } + return ''; +} diff --git a/src/chart/components/legend.ts b/src/chart/components/legend.ts index 305be1b..df929f9 100644 --- a/src/chart/components/legend.ts +++ b/src/chart/components/legend.ts @@ -4,6 +4,9 @@ import { BubbleData } from '../types/bubbleData.js'; import { BubbleChartOptions } from '../types/chartOptions.js'; import { measureTextWidth } from '../utils.js'; +const legendItemHeight = 20; +const legendYPadding = 10; + export async function createLegend( data: BubbleData[], svgWidth: number, @@ -12,16 +15,33 @@ export async function createLegend( chartOptions: BubbleChartOptions, ): Promise<{ svgLegend: string; legendHeight: number }> { try { - const legendMarginTop = distanceFromBubbleChart; // Distance from the last bubble to the legend - const legendItemHeight = 20; // Height for each legend row - const legendYPadding = 10; // Vertical padding between rows - const legendXPadding = 50; // Horizontal spacing between legend items + const legendItems = await prepareLegendItems(data, chartOptions); + const rowItems = groupLegendItemsIntoRows(legendItems, svgWidth); + const svgLegend = generateSVGForLegendRows( + rowItems, + svgWidth, + maxBubbleY, + distanceFromBubbleChart, + chartOptions, + ); - const legendY = maxBubbleY + legendMarginTop; // Start position for the legend - let svgLegend = ``; + const legendHeight = calculateLegendHeight(rowItems); + + return { svgLegend, legendHeight }; + } catch (error) { + throw new GeneratorError( + 'Failed to create legend.', + error instanceof Error ? error : undefined, + ); + } +} - // Prepare legend items with their measured widths - const legendItems = data.map(async (item) => { +async function prepareLegendItems( + data: BubbleData[], + chartOptions: BubbleChartOptions, +): Promise<{ text: string; width: number; color: string }[]> { + return Promise.all( + data.map(async (item) => { const value = chartOptions.displayValues === 'all' || chartOptions.displayValues === 'legend' @@ -33,63 +53,77 @@ export async function createLegend( const textWidth = await measureTextWidth(text, legendTextSize); return { text, - width: textWidth + legendXPadding, // Include circle and padding + width: textWidth + 50, // Include circle and padding color: item.color, }; - }); + }), + ); +} - const rowItems: { text: string; width: number; color: string }[][] = [[]]; // Array of rows, each row contains legend items - let currentRowWidth = 0; - let currentRowIndex = 0; +function groupLegendItemsIntoRows( + legendItems: { text: string; width: number; color: string }[], + svgWidth: number, +): { text: string; width: number; color: string }[][] { + const rowItems: { text: string; width: number; color: string }[][] = [[]]; + let currentRowWidth = 0; + let currentRowIndex = 0; - // Group legend items into rows based on svgWidth - for await (const i of legendItems) { - if (currentRowWidth + i.width > svgWidth) { - currentRowIndex++; - rowItems[currentRowIndex] = []; - currentRowWidth = 0; - } - rowItems[currentRowIndex].push(i); - currentRowWidth += i.width; + legendItems.forEach((item) => { + if (currentRowWidth + item.width > svgWidth) { + currentRowIndex++; + rowItems[currentRowIndex] = []; + currentRowWidth = 0; } + rowItems[currentRowIndex].push(item); + currentRowWidth += item.width; + }); - // Generate SVG for legend rows - let rowY = 0; - rowItems.forEach((row, rowIndex) => { - const rowWidth = row.reduce((sum, item) => sum + item.width, 0); - let rowX = 0; + return rowItems; +} - if (chartOptions.legendOptions.align === 'center') { - rowX = (svgWidth - rowWidth) / 2; - } else if (chartOptions.legendOptions.align === 'right') { - rowX = svgWidth - rowWidth + chartPadding; - } +function generateSVGForLegendRows( + rowItems: { text: string; width: number; color: string }[][], + svgWidth: number, + maxBubbleY: number, + distanceFromBubbleChart: number, + chartOptions: BubbleChartOptions, +): string { + const legendMarginTop = distanceFromBubbleChart; + const legendItemHeight = 20; + const legendYPadding = 10; + let svgLegend = ``; - let animationDelay = rowIndex; - row.forEach((item, itemIndex) => { - animationDelay += itemIndex * 0.1; - svgLegend += ` - - - ${item.text} - - `; - rowX += item.width; // Next item - }); - rowY += legendItemHeight + legendYPadding; // Next row - }); + let rowY = 0; + rowItems.forEach((row, rowIndex) => { + const rowWidth = row.reduce((sum, item) => sum + item.width, 0); + let rowX = 0; - svgLegend += ''; + if (chartOptions.legendOptions.align === 'center') { + rowX = (svgWidth - rowWidth) / 2; + } else if (chartOptions.legendOptions.align === 'right') { + rowX = svgWidth - rowWidth + chartPadding; + } - // Calculate the total height of the legend element - const legendHeight = - legendY - maxBubbleY - legendMarginTop + rowY + chartPadding; + let animationDelay = rowIndex; + row.forEach((item, itemIndex) => { + animationDelay += itemIndex * 0.1; + svgLegend += ` + + + ${item.text} + + `; + rowX += item.width; + }); + rowY += legendItemHeight + legendYPadding; + }); - return { svgLegend: svgLegend, legendHeight }; - } catch (error) { - throw new GeneratorError( - 'Failed to create legend.', - error instanceof Error ? error : undefined, - ); - } + svgLegend += ''; + return svgLegend; +} + +function calculateLegendHeight( + rowItems: { text: string; width: number; color: string }[][], +): number { + return rowItems.length * (legendItemHeight + legendYPadding) + chartPadding; } diff --git a/src/chart/components/title.ts b/src/chart/components/title.ts index 009572a..f56eb03 100644 --- a/src/chart/components/title.ts +++ b/src/chart/components/title.ts @@ -17,59 +17,32 @@ export async function createTitleElement( titleHeight: number, ): Promise<{ svgTitle: string; titleLines: number }> { try { - const style = Object.keys(titleOptions) - .filter( - (style) => - style !== 'text' && - style !== 'textAnchor' && - titleOptions[style] !== null, - ) - .map((style) => `${toKebabCase(style)}: ${titleOptions[style]};`) - .join(' '); - + const style = generateStyle(titleOptions); const titleAlign = getAlignmentPosition(titleOptions.textAnchor, width); - titleOptions.text = escapeSpecialChars(parseEmojis(titleOptions.text)); + const textWidth = await measureTextWidth( titleOptions.text, titleOptions.fontSize, titleOptions.fontWeight, ); - let textElement = ''; - let lines: string[] | null = null; - if (textWidth > width) { - lines = await wrapText( - titleOptions.text, - width, - titleOptions.fontSize, - titleOptions.fontWeight, - ); - - if (lines.length > 3) { - lines = lines.slice(0, 3); - lines[2] = truncateText(lines[2], lines[2].length - 3); - } - - lines.forEach((line, index) => { - textElement += ` - ${line} - `; - }); - } else { - textElement = titleOptions.text; - } + const { textElement, lines } = await generateTextElement( + titleOptions, + width, + titleHeight, + textWidth, + titleAlign, + ); return { - svgTitle: ` - - ${textElement} - - `, + svgTitle: generateSVGTitle( + titleOptions, + titleAlign, + titleHeight, + style, + textElement, + ), titleLines: lines?.length || 1, }; } catch (error) { @@ -79,3 +52,68 @@ export async function createTitleElement( ); } } + +function generateStyle(titleOptions: TitleOptions): string { + return Object.keys(titleOptions) + .filter( + (style) => + style !== 'text' && + style !== 'textAnchor' && + titleOptions[style] !== null, + ) + .map((style) => `${toKebabCase(style)}: ${titleOptions[style]};`) + .join(' '); +} + +async function generateTextElement( + titleOptions: TitleOptions, + width: number, + titleHeight: number, + textWidth: number, + titleAlign: number, +): Promise<{ textElement: string; lines: string[] | null }> { + let textElement = ''; + let lines: string[] | null = null; + + if (textWidth > width) { + lines = await wrapText( + titleOptions.text, + width, + titleOptions.fontSize, + titleOptions.fontWeight, + ); + + if (lines.length > 3) { + lines = lines.slice(0, 3); + lines[2] = truncateText(lines[2], lines[2].length - 3); + } + + lines.forEach((line, index) => { + textElement += ` + ${line} + `; + }); + } else { + textElement = titleOptions.text; + } + + return { textElement, lines }; +} + +function generateSVGTitle( + titleOptions: TitleOptions, + titleAlign: number, + titleHeight: number, + style: string, + textElement: string, +): string { + return ` + + ${textElement} + + `; +} diff --git a/src/chart/generator.ts b/src/chart/generator.ts index 635d23b..fc14dbb 100644 --- a/src/chart/generator.ts +++ b/src/chart/generator.ts @@ -1,10 +1,9 @@ -import { hierarchy, max, pack } from 'd3'; -import { createBubbleElement } from './components/bubble.js'; +import { hierarchy, HierarchyCircularNode, max, pack } from 'd3'; +import { processBubbleNodes } from './components/bubbles.js'; import { createLegend } from './components/legend.js'; import { createSVGDefs } from './defs.js'; import { getCommonStyles, - generateBubbleAnimationStyle, getLegendItemAnimationStyle, chartPadding, } from './styles.js'; @@ -14,41 +13,102 @@ import { escapeSpecialChars, measureTextHeight } from './utils.js'; import { GeneratorError } from '../errors/custom-errors.js'; import { createTitleElement } from './components/title.js'; +// TODO: refactor + adjust padding, spacing, etc. + export async function createBubbleChart( data: BubbleData[], chartOptions: BubbleChartOptions, ): Promise { - if (data === undefined || data.length === 0) return null; + if (!isValidData(data, chartOptions)) return null; + + const { width, height, borderWidth } = calculateDimensions(chartOptions); + + const bubbleNodes = generateBubbleNodes(data, width, height); + const { svgTitle, titleHeight, titleLines } = await generateTitle( + chartOptions, + width, + ); + + const { fullHeight, maxBubbleY, distanceFromBubbleChart } = + calculateFullHeight(bubbleNodes, titleHeight, titleLines); + + let styles = getCommonStyles(chartOptions.theme); + const { svgLegend, legendHeight } = await generateLegend( + data, + width, + maxBubbleY, + distanceFromBubbleChart, + chartOptions, + ); + styles += getLegendItemAnimationStyle(); + + const svg = await buildSVG({ + width, + fullHeight: fullHeight + legendHeight, + borderWidth, + borderColor: chartOptions.theme?.border?.color || 'transparent', + svgTitle, + distanceFromBubbleChart, + bubbleNodes, + chartOptions, + svgLegend, + styles, + }); + + return svg; +} + +function isValidData( + data: BubbleData[], + chartOptions: BubbleChartOptions, +): boolean { + if (data === undefined || data.length === 0) return false; if (isNaN(chartOptions.width) || isNaN(chartOptions.height)) { - throw new GeneratorError('Invalid width or hight.'); + throw new GeneratorError('Invalid width or height.'); } - if ( - chartOptions.titleOptions === undefined || - chartOptions.legendOptions === undefined - ) { + if (!chartOptions.titleOptions || !chartOptions.legendOptions) { throw new GeneratorError('Title or legend options are missing.'); } - // Escape special characters in data names so they can be shown correctly in the chart data.forEach((item) => { item.name = escapeSpecialChars(item.name); }); + return true; +} + +function calculateDimensions(chartOptions: BubbleChartOptions): { + width: number; + height: number; + borderWidth: number; +} { const borderWidth = chartOptions.theme?.border?.width || 0; const width = chartOptions.width + borderWidth * 2 + chartPadding * 2; const height = chartOptions.height + borderWidth * 2 + chartPadding * 2; + return { width, height, borderWidth }; +} +function generateBubbleNodes( + data: BubbleData[], + width: number, + height: number, +): HierarchyCircularNode[] { const bubblesPack = pack().size([width, height]).padding(1.5); const root = hierarchy({ children: data, } as unknown as BubbleData).sum((d) => d.value); - const bubbleNodes = bubblesPack(root).leaves(); + return bubblesPack(root).leaves(); +} - // Title +async function generateTitle( + chartOptions: BubbleChartOptions, + width: number, +): Promise<{ svgTitle: string; titleHeight: number; titleLines: number }> { let titleHeight = 0; - let { svgTitle, titleLines } = { svgTitle: '', titleLines: 0 }; + let svgTitle = ''; + let titleLines = 0; if (chartOptions.titleOptions.text) { titleHeight = await measureTextHeight( chartOptions.titleOptions.text, @@ -63,24 +123,33 @@ export async function createBubbleChart( svgTitle = title.svgTitle; titleLines = title.titleLines; } + return { svgTitle, titleHeight, titleLines }; +} - // Calculate full height +function calculateFullHeight( + bubbleNodes: HierarchyCircularNode[], + titleHeight: number, + titleLines: number, +): { fullHeight: number; maxBubbleY: number; distanceFromBubbleChart: number } { const bubbleChartMargin = 20; // Space between bubbles and title/legend const maxBubbleY = - max(bubbleNodes, (d) => d.y + d.r + bubbleChartMargin) || height; + max(bubbleNodes, (d) => d.y + d.r + bubbleChartMargin) || 0; const distanceFromBubbleChart = titleHeight * titleLines + bubbleChartMargin + chartPadding; - let fullHeight = maxBubbleY + distanceFromBubbleChart; - - // Common styles - let styles = getCommonStyles(chartOptions.theme); + const fullHeight = maxBubbleY + distanceFromBubbleChart; + return { fullHeight, maxBubbleY, distanceFromBubbleChart }; +} - // Legend +async function generateLegend( + data: BubbleData[], + width: number, + maxBubbleY: number, + distanceFromBubbleChart: number, + chartOptions: BubbleChartOptions, +): Promise<{ svgLegend: string; legendHeight: number }> { let svgLegend = ''; - if ( - chartOptions.legendOptions !== undefined && - chartOptions.legendOptions.show - ) { + let legendHeight = 0; + if (chartOptions.legendOptions?.show) { const legendResult = await createLegend( data, width, @@ -89,27 +158,48 @@ export async function createBubbleChart( chartOptions, ); svgLegend = legendResult.svgLegend; - fullHeight += legendResult.legendHeight; - styles += getLegendItemAnimationStyle(); + legendHeight = legendResult.legendHeight; } + return { svgLegend, legendHeight }; +} - // Start building the SVG - const borderColor = chartOptions.theme?.border?.color || 'transparent'; +async function buildSVG({ + width, + fullHeight, + borderWidth, + borderColor, + svgTitle, + distanceFromBubbleChart, + bubbleNodes, + chartOptions, + svgLegend, + styles, +}: { + width: number; + fullHeight: number; + borderWidth: number; + borderColor: string; + svgTitle: string; + distanceFromBubbleChart: number; + bubbleNodes: HierarchyCircularNode[]; + chartOptions: BubbleChartOptions; + svgLegend: string; + styles: string; +}): Promise { let svg = ``; svg += createSVGDefs(); svg += ``; - // svg += ``; svg += svgTitle; svg += ``; - for await (const [index, element] of bubbleNodes.entries()) { - svg += await createBubbleElement(element, index, chartOptions); - styles += generateBubbleAnimationStyle(element, index); - } + const { bubbleElements, bubbleStyles } = await processBubbleNodes( + bubbleNodes, + chartOptions, + ); + svg += bubbleElements; + styles += bubbleStyles; svg += ''; // Close bubbles group svg += svgLegend; - // svg += ''; // Close content group svg += ``; svg += ''; - return svg; } diff --git a/tests/chart/generator.test.ts b/tests/chart/generator.test.ts index 283786e..5798c84 100644 --- a/tests/chart/generator.test.ts +++ b/tests/chart/generator.test.ts @@ -13,7 +13,7 @@ import { } from '../../src/chart/types/chartOptions'; import { GeneratorError, StyleError } from '../../src/errors/custom-errors'; -// TODO: create components tests? +// TODO: adjust tests to new structure describe('Generator', () => { describe('createBubbleChart', () => { From 4418ad90d44bbcddee0996c0809cca8a78816db7 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 1 Mar 2025 16:47:35 +0100 Subject: [PATCH 05/12] Refactor legend component for improved layout and alignment handling --- src/chart/components/legend.ts | 13 ++++++------- src/chart/components/title.ts | 6 +++++- src/chart/generator.ts | 2 -- src/chart/styles.ts | 2 +- src/chart/utils.ts | 5 +++-- tests/chart/generator.test.ts | 2 -- tests/chart/utils.test.ts | 22 ++++++++++++++++++++++ 7 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/chart/components/legend.ts b/src/chart/components/legend.ts index df929f9..157f8f0 100644 --- a/src/chart/components/legend.ts +++ b/src/chart/components/legend.ts @@ -6,6 +6,8 @@ import { measureTextWidth } from '../utils.js'; const legendItemHeight = 20; const legendYPadding = 10; +const legendItemXPadding = 35; +const legendCircleRadius = 8; export async function createLegend( data: BubbleData[], @@ -53,7 +55,7 @@ async function prepareLegendItems( const textWidth = await measureTextWidth(text, legendTextSize); return { text, - width: textWidth + 50, // Include circle and padding + width: textWidth + legendCircleRadius * 2 + legendItemXPadding, // Include circle and padding color: item.color, }; }), @@ -88,10 +90,7 @@ function generateSVGForLegendRows( distanceFromBubbleChart: number, chartOptions: BubbleChartOptions, ): string { - const legendMarginTop = distanceFromBubbleChart; - const legendItemHeight = 20; - const legendYPadding = 10; - let svgLegend = ``; + let svgLegend = ``; let rowY = 0; rowItems.forEach((row, rowIndex) => { @@ -101,7 +100,7 @@ function generateSVGForLegendRows( if (chartOptions.legendOptions.align === 'center') { rowX = (svgWidth - rowWidth) / 2; } else if (chartOptions.legendOptions.align === 'right') { - rowX = svgWidth - rowWidth + chartPadding; + rowX = svgWidth - rowWidth; } let animationDelay = rowIndex; @@ -109,7 +108,7 @@ function generateSVGForLegendRows( animationDelay += itemIndex * 0.1; svgLegend += ` - + ${item.text} `; diff --git a/src/chart/components/title.ts b/src/chart/components/title.ts index f56eb03..c877d38 100644 --- a/src/chart/components/title.ts +++ b/src/chart/components/title.ts @@ -18,7 +18,11 @@ export async function createTitleElement( ): Promise<{ svgTitle: string; titleLines: number }> { try { const style = generateStyle(titleOptions); - const titleAlign = getAlignmentPosition(titleOptions.textAnchor, width); + const titleAlign = getAlignmentPosition( + titleOptions.textAnchor, + width, + chartPadding, + ); titleOptions.text = escapeSpecialChars(parseEmojis(titleOptions.text)); const textWidth = await measureTextWidth( diff --git a/src/chart/generator.ts b/src/chart/generator.ts index fc14dbb..a999ac2 100644 --- a/src/chart/generator.ts +++ b/src/chart/generator.ts @@ -13,8 +13,6 @@ import { escapeSpecialChars, measureTextHeight } from './utils.js'; import { GeneratorError } from '../errors/custom-errors.js'; import { createTitleElement } from './components/title.js'; -// TODO: refactor + adjust padding, spacing, etc. - export async function createBubbleChart( data: BubbleData[], chartOptions: BubbleChartOptions, diff --git a/src/chart/styles.ts b/src/chart/styles.ts index 13e770f..77cbca2 100644 --- a/src/chart/styles.ts +++ b/src/chart/styles.ts @@ -6,7 +6,7 @@ import { StyleError } from '../errors/custom-errors.js'; export const defaultFontFamily = "-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans',Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'"; -export const chartPadding = 8; +export const chartPadding = 10; export const chartBorderRadius = 10; export const legendTextSize = '13px'; diff --git a/src/chart/utils.ts b/src/chart/utils.ts index 08c3424..c693c2e 100644 --- a/src/chart/utils.ts +++ b/src/chart/utils.ts @@ -166,14 +166,15 @@ export async function wrapText( export function getAlignmentPosition( textAnchor: TextAnchor, width: number, + padding: number = 0, ): number { switch (textAnchor) { case 'start': - return 0; + return 0 + padding; case 'middle': return width / 2; case 'end': - return width; + return width - padding; default: return width / 2; } diff --git a/tests/chart/generator.test.ts b/tests/chart/generator.test.ts index 5798c84..6bcf381 100644 --- a/tests/chart/generator.test.ts +++ b/tests/chart/generator.test.ts @@ -13,8 +13,6 @@ import { } from '../../src/chart/types/chartOptions'; import { GeneratorError, StyleError } from '../../src/errors/custom-errors'; -// TODO: adjust tests to new structure - describe('Generator', () => { describe('createBubbleChart', () => { it('should return null if no data is provided', async () => { diff --git a/tests/chart/utils.test.ts b/tests/chart/utils.test.ts index 50748bd..b66ffaf 100644 --- a/tests/chart/utils.test.ts +++ b/tests/chart/utils.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, vi, Mock } from 'vitest'; import { BubbleData } from '../../src/chart/types/bubbleData'; +import { TextAnchor } from '../../src/chart/types/chartOptions'; import { getColor, getName, toKebabCase, getBubbleData, + getAlignmentPosition, } from '../../src/chart/utils'; import { fetchTopLanguages } from '../../src/services/github-service'; @@ -69,4 +71,24 @@ describe('Utils', () => { ]); }); }); + + describe('getAlignmentPosition', () => { + it('should return the correct position for start alignment', () => { + expect(getAlignmentPosition('start', 100, 10)).toBe(10); + }); + + it('should return the correct position for middle alignment', () => { + expect(getAlignmentPosition('middle', 100)).toBe(50); + }); + + it('should return the correct position for end alignment', () => { + expect(getAlignmentPosition('end', 100, 10)).toBe(90); + }); + + it('should return the correct position for default alignment', () => { + expect( + getAlignmentPosition('unknown' as unknown as TextAnchor, 100), + ).toBe(50); + }); + }); }); From 6288dd4d44ab2838a6fb837ed3f0d5ba386542c1 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 1 Mar 2025 16:48:16 +0100 Subject: [PATCH 06/12] Remove black background from common chart styles --- src/chart/styles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/chart/styles.ts b/src/chart/styles.ts index 77cbca2..0989ce1 100644 --- a/src/chart/styles.ts +++ b/src/chart/styles.ts @@ -15,7 +15,6 @@ export function getCommonStyles(theme: ThemeBase): string { return ` svg { font-family: ${defaultFontFamily}; - background: black; } .chart-background { fill: ${theme.backgroundColor}; From b50067e21985495e37f3506e2264fe756e3c625d Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Thu, 20 Mar 2025 00:00:00 +0100 Subject: [PATCH 07/12] Add theme configuration for changed files in labels.yml --- .github/config/labels.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/config/labels.yml b/.github/config/labels.yml index 6e87793..3164175 100644 --- a/.github/config/labels.yml +++ b/.github/config/labels.yml @@ -19,3 +19,8 @@ configuration: - .prettierrc* - .editorconfig - .stylelintrc* + +theme: + - changed-files: + - any-glob-to-any-file: + - src/chart/themes.ts From 3ef003cae2820effe65aad54c01548abd55b2b1f Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Thu, 20 Mar 2025 00:00:11 +0100 Subject: [PATCH 08/12] Remove unnecessary blank lines in CONTRIBUTING.md --- CONTRIBUTING.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 586d0a9..017573b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,8 +11,6 @@ We are thrilled that you are considering contributing to this project! Your cont 5. [Style Guide](#style-guide) 6. [Need Help?](#need-help) ---- - ## Getting Started To get started, follow these steps: From 1dfbb68c3a74dd863986875f5f3133be3323637d Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Thu, 20 Mar 2025 00:00:21 +0100 Subject: [PATCH 09/12] Update pull request template for improved clarity and formatting --- .github/pull_request_template.md | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ddcb6b9..53ca086 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,15 +7,10 @@ Before submitting your PR, confirm the following: - [ ] Commit messages follow the [contribution guidelines](https://github.com/teociaps/github-bubble-chart/blob/main/CONTRIBUTING.md). - [ ] Tests are added or updated to cover all changes. - [ ] Documentation is updated (e.g., README, inline comments, external docs). -- [ ] CI checks pass successfully. - ---- ## **Summary** -Provide a concise summary of this PR, including its purpose, the problem it solves, and the key changes made. - ---- +_Provide a concise summary of this PR, including its purpose, the problem it solves, and the key changes made._ ## **Change Type** @@ -28,22 +23,16 @@ Select the type(s) of change included in this PR: - [ ] Maintenance / Chores - [ ] Breaking change -If this PR introduces a breaking change, describe the impact and required migration steps: - ---- +If this PR introduces a **breaking change**, describe the impact and required migration steps: ## **Linked Issues** -List any related issues or feature requests (e.g., Fixes #123). - ---- - -## **Screenshots/Logs (Optional)** +_List any related issues or feature requests (e.g., Fixes #123)._ -Include relevant screenshots, logs, or other visuals, if applicable. +## **Screenshots/Logs** ---- +_Include relevant screenshots, logs, or other visuals, if applicable._ ## **Additional Notes** -Provide any additional information or feedback requests for reviewers. +_Provide any additional information or feedback requests for reviewers._ From db53f12ec0b33b1a80c5f20b2fd820a1eb7d5b1e Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 22 Mar 2025 17:55:34 +0100 Subject: [PATCH 10/12] Refactor environment variables and update README for clarity --- .env.example | 6 ------ .prettierrc | 3 ++- README.md | 12 ++++++++---- config/consts.ts | 6 ++++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index a8b6c69..07b5f69 100644 --- a/.env.example +++ b/.env.example @@ -13,12 +13,6 @@ DEFAULT_GITHUB_RETRY_DELAY=1000 # Maximum number of retry attempts for GitHub API calls (e.g., 3) DEFAULT_GITHUB_MAX_RETRY=3 -# URL for retrieving Devicon assets (ensure it ends with a slash) -DEVICON_URL='https://raw.githubusercontent.com/devicons/devicon/master/icons/' - -# URL to fetch GitHub Linguist language definitions -LINGUIST_GITHUB='https://raw.githubusercontent.com/github/linguist/main/lib/linguist/languages.yml' - # Base URL for language mappings file location LANGUAGE_MAPPINGS_URL='https://raw.githubusercontent.com/teociaps/github-bubble-chart/main/' diff --git a/.prettierrc b/.prettierrc index d909c93..94848b1 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,6 @@ "trailingComma": "all", "printWidth": 80, "tabWidth": 2, - "useTabs": false + "useTabs": false, + "endOfLine": "auto" } diff --git a/README.md b/README.md index 7ea8f5e..1f9d8e7 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ Insert a bubble chart into your GitHub profile readme. This makes for a great [G You can insert the bubble chart into your GitHub profile readme by using the following URL format: ``` -https://github-bubble-chart.vercel.app?username= +https://github-bubble-chart.vercel.app?username=YOUR_GITHUB_USERNAME ``` -For detailed usage instructions, please refer to our [Wiki](https://github.com/teociaps/github-bubble-chart/wiki). +For detailed usage instructions, please refer to the [Wiki](https://github.com/teociaps/github-bubble-chart/wiki). ### Examples @@ -28,12 +28,16 @@ For detailed usage instructions, please refer to our [Wiki](https://github.com/t ![teociaps](https://github-bubble-chart.vercel.app?username=teociaps&theme=dark_dimmed&title-size=34&title-color=red&legend-align=left) +``` +https://github-bubble-chart.vercel.app?username=teociaps&theme=dark_dimmed&title-size=34&title-color=red&legend-align=left +``` + #### Custom Configuration Example -![Custom](https://github-bubble-chart.vercel.app/?username=teociaps&mode=custom-config&config-path=ghbc-my-tech-and-tools.json) +![Custom](https://github-bubble-chart.vercel.app?username=teociaps&mode=custom-config&config-path=ghbc-my-tech-and-tools.json) ``` -https://github-bubble-chart.vercel.app?username=teociap&mode=custom-config&config-path=ghbc-my-tech-and-tools.json +https://github-bubble-chart.vercel.app?username=teociaps&mode=custom-config&config-path=ghbc-my-tech-and-tools.json ``` ## Breaking Changes & Releases diff --git a/config/consts.ts b/config/consts.ts index 6509d3d..a11bb85 100644 --- a/config/consts.ts +++ b/config/consts.ts @@ -14,8 +14,10 @@ const CONSTANTS = { DEFAULT_GITHUB_MAX_RETRY: parseInt(process.env.DEFAULT_GITHUB_MAX_RETRY!, 10), REVALIDATE_TIME: HOUR_IN_MILLISECONDS, LANGS_OUTPUT_FILE: 'src/languageMappings.json', - DEVICON_URL: process.env.DEVICON_URL!, - LINGUIST_GITHUB: process.env.LINGUIST_GITHUB!, + DEVICON_URL: + 'https://raw.githubusercontent.com/devicons/devicon/master/icons/', + LINGUIST_GITHUB: + 'https://raw.githubusercontent.com/github/linguist/main/lib/linguist/languages.yml', LANGUAGE_MAPPINGS_URL: process.env.LANGUAGE_MAPPINGS_URL!, DEFAULT_THEME: new DefaultTheme(), }; From 009fca3aed6de2593fd2627568ea9fc2e8449de2 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sun, 23 Mar 2025 00:28:57 +0100 Subject: [PATCH 11/12] Refactor environment utility functions and enhance image conversion logic --- api/utils.ts | 41 ++- scripts/fetchLanguageMappings.ts | 11 +- src/common/environment.ts | 15 ++ src/common/utils.ts | 73 +++++- src/logger.ts | 2 +- tests/api/utils.test.ts | 438 +++++++++++++++++++++---------- tests/common/utils.test.ts | 144 +++++++++- 7 files changed, 564 insertions(+), 160 deletions(-) create mode 100644 src/common/environment.ts diff --git a/api/utils.ts b/api/utils.ts index e9edf33..1785efa 100644 --- a/api/utils.ts +++ b/api/utils.ts @@ -14,9 +14,10 @@ import { DisplayMode, } from '../src/chart/types/chartOptions.js'; import { CustomConfig, Mode } from '../src/chart/types/config.js'; +import { isDevEnvironment } from '../src/common/environment.js'; import { - isDevEnvironment, mapConfigToBubbleChartOptions, + convertImageToBase64, } from '../src/common/utils.js'; import { BaseError } from '../src/errors/base-error.js'; import { @@ -162,15 +163,40 @@ export async function fetchConfigFromRepo( filePath: string, branch?: string, ): Promise<{ options: BubbleChartOptions; data: BubbleData[] }> { - const processConfig = ( + const processConfig = async ( customConfig: CustomConfig, - ): { options: BubbleChartOptions; data: BubbleData[] } => { - const options = mapConfigToBubbleChartOptions(customConfig.options); + ): Promise<{ options: BubbleChartOptions; data: BubbleData[] }> => { + // First filter invalid data items customConfig.data = customConfig.data.filter( (dataItem) => typeof dataItem.value === 'number' && !isNaN(dataItem.value), ); - return { options: options, data: customConfig.data }; + + // Process icons in data items if they exist + for (const dataItem of customConfig.data) { + // Check if item has an icon property that's a URL string and not already base64 + if ( + dataItem.icon && + typeof dataItem.icon === 'string' && + !dataItem.icon.startsWith('data:') + ) { + try { + const base64Icon = await convertImageToBase64(dataItem.icon); + if (base64Icon) { + dataItem.icon = base64Icon; + } + } catch (error) { + logger.warn( + `Failed to convert icon to base64: ${dataItem.icon}`, + error, + ); + // Continue with original URL if conversion fails + } + } + } + + const options = mapConfigToBubbleChartOptions(customConfig.options); + return { options, data: customConfig.data }; }; if (isDevEnvironment()) { @@ -182,7 +208,7 @@ export async function fetchConfigFromRepo( const customConfig = JSON.parse( fs.readFileSync(localPath, 'utf-8'), ) as CustomConfig; - return processConfig(customConfig); + return await processConfig(customConfig); } catch (error) { throw new ValidationError( 'Failed to parse local JSON configuration.', @@ -219,7 +245,7 @@ export async function fetchConfigFromRepo( try { const customConfig = (await response.json()) as CustomConfig; - return processConfig(customConfig); + return await processConfig(customConfig); } catch (error) { throw new ValidationError( 'Failed to parse fetched JSON configuration.', @@ -240,3 +266,4 @@ export function handleErrorResponse( res.status(500).send({ error: 'An unexpected error occurred' }); } } +export { convertImageToBase64 }; diff --git a/scripts/fetchLanguageMappings.ts b/scripts/fetchLanguageMappings.ts index 7b13438..850e284 100644 --- a/scripts/fetchLanguageMappings.ts +++ b/scripts/fetchLanguageMappings.ts @@ -1,8 +1,8 @@ /* eslint-disable no-console */ import fs from 'fs'; -import imageToBase64 from 'image-to-base64'; import { parse as yamlParse } from 'yaml'; import { CONSTANTS } from '../config/consts'; +import { convertImageToBase64 } from '../src/common/utils'; // Known language name discrepancies map (GitHub vs Devicon) const languageDiscrepancies: Record = { @@ -45,15 +45,6 @@ async function fetchLanguageColors(): Promise< } } -async function convertImageToBase64(url: string): Promise { - try { - const base64 = await imageToBase64(url); - return `data:image/svg+xml;base64,${base64}`; - } catch (error) { - console.error('Error converting image:', error); - } -} - const svgVersions = [ '-original', '-plain', diff --git a/src/common/environment.ts b/src/common/environment.ts new file mode 100644 index 0000000..1363516 --- /dev/null +++ b/src/common/environment.ts @@ -0,0 +1,15 @@ +/** + * Checks if the current environment is development + * @returns true if NODE_ENV is 'dev' + */ +export const isDevEnvironment = (): boolean => { + return process.env.NODE_ENV === 'dev'; +}; + +/** + * Checks if the current environment is production + * @returns true if NODE_ENV is 'prod' + */ +export const isProdEnvironment = (): boolean => { + return process.env.NODE_ENV === 'prod'; +}; diff --git a/src/common/utils.ts b/src/common/utils.ts index 292fc76..78beebf 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,14 +1,8 @@ +import imageToBase64 from 'image-to-base64'; import { themeMap } from '../chart/themes.js'; import { BubbleChartOptions } from '../chart/types/chartOptions.js'; import { CustomConfigOptions } from '../chart/types/config.js'; - -export const isDevEnvironment = (): boolean => { - return process.env.NODE_ENV === 'dev'; -}; - -export const isProdEnvironment = (): boolean => { - return process.env.NODE_ENV === 'prod'; -}; +import logger from '../logger.js'; export function mapConfigToBubbleChartOptions( config: CustomConfigOptions, @@ -52,3 +46,66 @@ export function getPxValue(value: string): number { } return 0; } + +export async function convertImageToBase64( + url: string, + options?: { timeout?: number }, +): Promise { + if (!url || typeof url !== 'string') { + logger.error('Invalid URL provided to convertImageToBase64'); + return undefined; + } + + try { + new URL(url); + } catch { + logger.error('Invalid URL format', { url }); + return undefined; + } + + try { + const timeoutMs = options?.timeout || 10000; // 10 second timeout + + const base64 = await Promise.race([ + imageToBase64(url), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Image conversion timed out')), + timeoutMs, + ), + ), + ]); + + // MIME type detection + const mimeTypeMap: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + bmp: 'image/bmp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + tiff: 'image/tiff', + tif: 'image/tiff', + avif: 'image/avif', + }; + + // Extract extension from URL, handling query params and fragments + const urlPath = new URL(url).pathname; + const extension = urlPath.split('.').pop()?.toLowerCase().split(/[?#]/)[0]; + + // Default to a generic image type if we can't determine the specific type + const mimeType = + extension && mimeTypeMap[extension] + ? mimeTypeMap[extension] + : 'image/png'; + + return `data:${mimeType};base64,${base64}`; + } catch (_error) { + const errorMessage = + _error instanceof Error ? _error.message : 'Unknown error'; + logger.error(`Error converting image to base64: ${errorMessage}`, _error); + return undefined; + } +} diff --git a/src/logger.ts b/src/logger.ts index 5d03eb5..8c87ddd 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,5 +1,5 @@ import { pino } from 'pino'; -import { isDevEnvironment } from './common/utils.js'; +import { isDevEnvironment } from './common/environment.js'; const isDev = isDevEnvironment(); diff --git a/tests/api/utils.test.ts b/tests/api/utils.test.ts index 95d018f..5472124 100644 --- a/tests/api/utils.test.ts +++ b/tests/api/utils.test.ts @@ -6,56 +6,107 @@ import { CustomURLSearchParams, parseParams, fetchConfigFromRepo, + convertImageToBase64, } from '../../api/utils'; import { LightTheme } from '../../src/chart/themes'; import { CustomConfig } from '../../src/chart/types/config'; -import { - isDevEnvironment, - mapConfigToBubbleChartOptions, -} from '../../src/common/utils'; +import { isDevEnvironment } from '../../src/common/environment'; +import { mapConfigToBubbleChartOptions } from '../../src/common/utils'; import { FetchError, ValidationError } from '../../src/errors/custom-errors'; import { GitHubNotFoundError, GitHubRateLimitError, } from '../../src/errors/github-errors'; +import logger from '../../src/logger'; describe('API Utils', () => { describe('CustomURLSearchParams', () => { - it('should return default string value if key is not present', () => { + const testCases = [ + { + name: 'string value', + param: 'key=value', + method: 'getStringValue', + key: 'key', + defaultVal: 'default', + expected: 'value', + }, + { + name: 'number value', + param: 'key=42', + method: 'getNumberValue', + key: 'key', + defaultVal: 0, + expected: 42, + }, + { + name: 'boolean value', + param: 'key=true', + method: 'getBooleanValue', + key: 'key', + defaultVal: false, + expected: true, + }, + { + name: 'title', + param: 'title=MyChart', + method: 'getStringValue', + key: 'title', + defaultVal: 'Bubble Chart', + expected: 'MyChart', + }, + { + name: 'legend alignment', + param: 'legend-align=right', + method: 'getStringValue', + key: 'legend-align', + defaultVal: 'center', + expected: 'right', + }, + { + name: 'title size', + param: 'title-size=30', + method: 'getNumberValue', + key: 'title-size', + defaultVal: 24, + expected: 30, + }, + { + name: 'title weight', + param: 'title-weight=normal', + method: 'getStringValue', + key: 'title-weight', + defaultVal: 'bold', + expected: 'normal', + }, + { + name: 'title color', + param: 'title-color=#ffffff', + method: 'getStringValue', + key: 'title-color', + defaultVal: '#000000', + expected: '#ffffff', + }, + ]; + + it('should return default values when keys are not present', () => { const params = new CustomURLSearchParams(''); expect(params.getStringValue('key', 'default')).toBe('default'); - }); - - it('should return string value if key is present', () => { - const params = new CustomURLSearchParams('key=value'); - expect(params.getStringValue('key', 'default')).toBe('value'); - }); - - it('should return default number value if key is not present', () => { - const params = new CustomURLSearchParams(''); expect(params.getNumberValue('key', 0)).toBe(0); - }); - - it('should return parsed number value if key is present', () => { - const params = new CustomURLSearchParams('key=42'); - expect(params.getNumberValue('key', 0)).toBe(42); - }); - - it('should return default boolean value if key is not present', () => { - const params = new CustomURLSearchParams(''); expect(params.getBooleanValue('key', true)).toBe(true); - }); - - it('should return parsed boolean value if key is present', () => { - const params = new CustomURLSearchParams('key=true'); - expect(params.getBooleanValue('key', false)).toBe(true); - }); - - it('should return default theme if key is not present', () => { - const params = new CustomURLSearchParams(''); expect(params.getTheme('theme', new LightTheme())).toBeInstanceOf( LightTheme, ); + expect(params.getTextAnchorValue('key', 'middle')).toBe('middle'); + expect(params.getLanguagesCount(5)).toBe(5); + expect(params.getMode()).toBe('top-langs'); + expect(params.getValuesDisplayOption('display-values')).toBe('legend'); + }); + + testCases.forEach(({ name, param, method, key, defaultVal, expected }) => { + it(`should return ${name} if key is present`, () => { + const params = new CustomURLSearchParams(param); + expect(params[method](key, defaultVal)).toBe(expected); + }); }); it('should return parsed theme if key is present', () => { @@ -75,24 +126,24 @@ describe('API Utils', () => { expect(params.getTextAnchorValue('key', 'middle')).toBe('middle'); }); - it('should return default languages count if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getLanguagesCount(5)).toBe(5); - }); + it('should handle languages count correctly', () => { + // Default + expect(new CustomURLSearchParams('').getLanguagesCount(5)).toBe(5); - it('should return parsed languages count if key is present', () => { - const params = new CustomURLSearchParams('langs-count=10'); - expect(params.getLanguagesCount(5)).toBe(10); - }); + // Valid value + expect( + new CustomURLSearchParams('langs-count=10').getLanguagesCount(5), + ).toBe(10); - it('should return minimum languages count if parsed value is less than 1', () => { - const params = new CustomURLSearchParams('langs-count=0'); - expect(params.getLanguagesCount(5)).toBe(1); - }); + // Too small (should return minimum) + expect( + new CustomURLSearchParams('langs-count=0').getLanguagesCount(5), + ).toBe(1); - it('should return maximum languages count if parsed value is greater than 20', () => { - const params = new CustomURLSearchParams('langs-count=21'); - expect(params.getLanguagesCount(5)).toBe(20); + // Too large (should return maximum) + expect( + new CustomURLSearchParams('langs-count=21').getLanguagesCount(5), + ).toBe(20); }); it('should parse title options correctly', () => { @@ -120,96 +171,41 @@ describe('API Utils', () => { }); }); - it('should return default mode if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getMode()).toBe('top-langs'); - }); + // Mode tests - consolidated + it('should handle mode correctly', () => { + // Default + expect(new CustomURLSearchParams('').getMode()).toBe('top-langs'); - it('should return parsed mode if key is present', () => { - const params = new CustomURLSearchParams('mode=custom-config'); - expect(params.getMode()).toBe('custom-config'); - }); - - it('should return default mode if parsed mode is invalid', () => { - const params = new CustomURLSearchParams('mode=invalid-mode'); - expect(params.getMode()).toBe('top-langs'); - }); - - it('should return default values display option if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getValuesDisplayOption('display-values')).toBe('legend'); - }); - - it('should return parsed values display option if key is present', () => { - const params = new CustomURLSearchParams('display-values=all'); - expect(params.getValuesDisplayOption('display-values')).toBe('all'); - }); - - it('should return default values display option if parsed value is invalid', () => { - const params = new CustomURLSearchParams('display-values=invalid'); - expect(params.getValuesDisplayOption('display-values')).toBe('legend'); - }); - - it('should return default title if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('title', 'Bubble Chart')).toBe( - 'Bubble Chart', + // Valid mode + expect(new CustomURLSearchParams('mode=custom-config').getMode()).toBe( + 'custom-config', ); - }); - - it('should return parsed title if key is present', () => { - const params = new CustomURLSearchParams('title=MyChart'); - expect(params.getStringValue('title', 'Bubble Chart')).toBe('MyChart'); - }); - - it('should return default legend alignment if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('legend-align', 'center')).toBe('center'); - }); - - it('should return parsed legend alignment if key is present', () => { - const params = new CustomURLSearchParams('legend-align=right'); - expect(params.getStringValue('legend-align', 'center')).toBe('right'); - }); - - it('should return default title size if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getNumberValue('title-size', 24)).toBe(24); - }); - - it('should return parsed title size if key is present', () => { - const params = new CustomURLSearchParams('title-size=30'); - expect(params.getNumberValue('title-size', 24)).toBe(30); - }); - - it('should return default title weight if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('title-weight', 'bold')).toBe('bold'); - }); - - it('should return parsed title weight if key is present', () => { - const params = new CustomURLSearchParams('title-weight=normal'); - expect(params.getStringValue('title-weight', 'bold')).toBe('normal'); - }); - it('should return default title color if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('title-color', '#000000')).toBe('#000000'); + // Invalid mode (should return default) + expect(new CustomURLSearchParams('mode=invalid-mode').getMode()).toBe( + 'top-langs', + ); }); - it('should return parsed title color if key is present', () => { - const params = new CustomURLSearchParams('title-color=#ffffff'); - expect(params.getStringValue('title-color', '#000000')).toBe('#ffffff'); - }); + it('should handle values display option correctly', () => { + // Default + expect( + new CustomURLSearchParams('').getValuesDisplayOption('display-values'), + ).toBe('legend'); - it('should return default title alignment if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getTextAnchorValue('title-align', 'middle')).toBe('middle'); - }); + // Valid option + expect( + new CustomURLSearchParams('display-values=all').getValuesDisplayOption( + 'display-values', + ), + ).toBe('all'); - it('should return parsed title alignment if key is present', () => { - const params = new CustomURLSearchParams('title-align=center'); - expect(params.getTextAnchorValue('title-align', 'middle')).toBe('middle'); + // Invalid option (should return default) + expect( + new CustomURLSearchParams( + 'display-values=invalid', + ).getValuesDisplayOption('display-values'), + ).toBe('legend'); }); }); @@ -230,13 +226,29 @@ describe('API Utils', () => { vi.mock('fs'); vi.mock('path'); vi.mock('../../src/common/utils', () => ({ - isDevEnvironment: vi.fn(), mapConfigToBubbleChartOptions: vi.fn().mockReturnValue({ titleOptions: { text: 'Test Chart' }, - } as unknown as CustomConfig), + }), + convertImageToBase64: vi.fn().mockImplementation(async (url) => { + if (url === 'https://example.com/icon.png') { + return 'data:image/png;base64,converted'; + } + return undefined; + }), + })); + vi.mock('../../src/common/environment', () => ({ + isDevEnvironment: vi.fn(), })); vi.stubGlobal('fetch', vi.fn()); + // logger mock to include the warn method + vi.mock('../../src/logger', () => ({ + default: { + error: vi.fn(), + warn: vi.fn(), + }, + })); + const mockConfig: CustomConfig = { options: { titleOptions: { text: 'Test Chart' }, @@ -354,5 +366,167 @@ describe('API Utils', () => { ValidationError, ); }); + + it('handles icon URL conversion cases correctly', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(true); + const localPath = '/example-config.json'; + vi.mocked(path.resolve).mockReturnValue(localPath); + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Case 1: Normal conversion + const configWithIconData = { + options: { titleOptions: { text: 'Test Chart' } }, + data: [ + { + name: 'Node.js', + value: 50, + color: '#68A063', + icon: 'https://example.com/icon.png', + }, + { name: 'Python', value: 30, color: '#3776AB' }, // No icon + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithIconData), + ); + let result = await fetchConfigFromRepo('username', 'filePath'); + expect(convertImageToBase64).toHaveBeenCalledWith( + 'https://example.com/icon.png', + ); + expect(result.data[0].icon).toBe('data:image/png;base64,converted'); + expect(result.data[1].icon).toBeUndefined(); + + vi.clearAllMocks(); + + // Case 2: Already base64 icon + const configWithBase64IconData = { + options: { titleOptions: { text: 'Test Chart' } }, + data: [ + { + name: 'Node.js', + value: 50, + color: '#68A063', + icon: 'data:image/png;base64,alreadyBase64', + }, + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithBase64IconData), + ); + result = await fetchConfigFromRepo('username', 'filePath'); + expect(convertImageToBase64).not.toHaveBeenCalled(); + expect(result.data[0].icon).toBe('data:image/png;base64,alreadyBase64'); + + vi.clearAllMocks(); + + // Case 3: Failed conversion + const configWithBadIconData = { + options: { titleOptions: { text: 'Test Chart' } }, + data: [ + { + name: 'Broken', + value: 50, + color: '#FF0000', + icon: 'https://example.com/bad-icon.png', + }, + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithBadIconData), + ); + result = await fetchConfigFromRepo('username', 'filePath'); + expect(convertImageToBase64).toHaveBeenCalledWith( + 'https://example.com/bad-icon.png', + ); + expect(result.data[0].icon).toBe('https://example.com/bad-icon.png'); + }); + + it('filters out data items with invalid values', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(true); + const localPath = '/example-config.json'; + vi.mocked(path.resolve).mockReturnValue(localPath); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const configWithInvalidData = { + options: { + titleOptions: { text: 'Test Chart' }, + }, + data: [ + { name: 'Valid Number', value: 50, color: '#68A063' }, // Valid + { name: 'String Value', value: '50', color: '#3776AB' }, // Invalid - string + { name: 'NaN Value', value: NaN, color: '#FF0000' }, // Invalid - NaN + { name: 'Undefined Value', value: undefined, color: '#00FF00' }, // Invalid - undefined + { name: 'Null Value', value: null, color: '#0000FF' }, // Invalid - null + { name: 'Object Value', value: {}, color: '#FFFF00' }, // Invalid - object + { name: 'Zero', value: 0, color: '#FF00FF' }, // Valid - zero is a valid number + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithInvalidData), + ); + + const result = await fetchConfigFromRepo('username', 'filePath'); + + // Only the valid numeric values should remain + expect(result.data.length).toBe(2); + expect(result.data[0].name).toBe('Valid Number'); + expect(result.data[1].name).toBe('Zero'); + + // Verify the invalid items were filtered out + const dataNames = result.data.map((item) => item.name); + expect(dataNames).not.toContain('String Value'); + expect(dataNames).not.toContain('NaN Value'); + expect(dataNames).not.toContain('Undefined Value'); + expect(dataNames).not.toContain('Null Value'); + expect(dataNames).not.toContain('Object Value'); + }); + + it('catches and logs exceptions during icon conversion', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(true); + const localPath = '/example-config.json'; + vi.mocked(path.resolve).mockReturnValue(localPath); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const configWithIconData = { + options: { + titleOptions: { text: 'Test Chart' }, + }, + data: [ + { + name: 'Node.js', + value: 50, + color: '#68A063', + icon: 'https://example.com/error-icon.png', + }, + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithIconData), + ); + + // Mock convertImageToBase64 to throw an exception + vi.mocked(convertImageToBase64).mockImplementation(async (url) => { + if (url === 'https://example.com/error-icon.png') { + throw new Error('Conversion error'); + } + return 'data:image/png;base64,converted'; + }); + + const result = await fetchConfigFromRepo('username', 'filePath'); + + // Verify the function caught the exception + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to convert icon to base64: https://example.com/error-icon.png', + expect.any(Error), + ); + + // Verify the original URL is preserved + expect(result.data[0].icon).toBe('https://example.com/error-icon.png'); + }); }); }); diff --git a/tests/common/utils.test.ts b/tests/common/utils.test.ts index aed24be..cbbedbe 100644 --- a/tests/common/utils.test.ts +++ b/tests/common/utils.test.ts @@ -1,15 +1,36 @@ -import { describe, it, expect } from 'vitest'; +import imageToBase64 from 'image-to-base64'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { themeMap } from '../../src/chart/themes'; import { BubbleChartOptions } from '../../src/chart/types/chartOptions'; import { CustomConfigOptions } from '../../src/chart/types/config'; import { - getPxValue, isDevEnvironment, isProdEnvironment, +} from '../../src/common/environment'; +import { + getPxValue, mapConfigToBubbleChartOptions, truncateText, + convertImageToBase64, } from '../../src/common/utils'; +// Mock the imageToBase64 dependency +vi.mock('image-to-base64', () => { + return { + default: vi.fn(), + }; +}); + +// Mock logger to avoid actual logging during tests +vi.mock('../../src/logger', () => ({ + default: { + error: vi.fn(), + }, +})); + +// Import the mocked modules for direct access in tests +import logger from '../../src/logger'; + describe('Utils Tests', () => { it('isDevEnvironment should return true if NODE_ENV is dev', () => { process.env.NODE_ENV = 'dev'; @@ -185,4 +206,123 @@ describe('Utils Tests', () => { expect(getPxValue('3px solid red')).toBe(3); }); }); + + describe('convertImageToBase64', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(imageToBase64).mockResolvedValue('mockBase64Data'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should convert PNG image URL to base64 data URL', async () => { + const pngUrl = + 'https://raw.githubusercontent.com/github/explore/80688e429a7d4ef2fca1e82350fe8e3517d3494d/topics/javascript/javascript.png'; + const result = await convertImageToBase64(pngUrl); + expect(imageToBase64).toHaveBeenCalledWith(pngUrl); + expect(result).toBe('data:image/png;base64,mockBase64Data'); + }); + + it('should convert JPG image URL to base64 data URL', async () => { + const jpgUrl = + 'https://www.nasa.gov/wp-content/uploads/2023/03/pia25447-10731.jpg'; + const result = await convertImageToBase64(jpgUrl); + expect(imageToBase64).toHaveBeenCalledWith(jpgUrl); + expect(result).toBe('data:image/jpeg;base64,mockBase64Data'); + }); + + it('should convert SVG image URL to base64 data URL', async () => { + const svgUrl = + 'https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/android.svg'; + const result = await convertImageToBase64(svgUrl); + expect(imageToBase64).toHaveBeenCalledWith(svgUrl); + expect(result).toBe('data:image/svg+xml;base64,mockBase64Data'); + }); + + it('should convert GIF image URL to base64 data URL', async () => { + const gifUrl = + 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExbDUzOGk0M3lvejY5OHgwaHgwZTlrYTc3Z3lsOW12ejl1MTkxMmwwayZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/3o7TKSjRrfIPjeiVyM/giphy.gif'; + const result = await convertImageToBase64(gifUrl); + expect(imageToBase64).toHaveBeenCalledWith(gifUrl); + expect(result).toBe('data:image/gif;base64,mockBase64Data'); + }); + + it('should convert WebP image URL to base64 data URL', async () => { + const webpUrl = 'https://www.gstatic.com/webp/gallery/1.webp'; + const result = await convertImageToBase64(webpUrl); + expect(imageToBase64).toHaveBeenCalledWith(webpUrl); + expect(result).toBe('data:image/webp;base64,mockBase64Data'); + }); + + it('should handle URLs with query parameters correctly', async () => { + const imageWithParams = + 'https://avatars.githubusercontent.com/u/9919?s=200&v=4'; + const result = await convertImageToBase64(imageWithParams); + expect(imageToBase64).toHaveBeenCalledWith(imageWithParams); + expect(result).toBe('data:image/png;base64,mockBase64Data'); + }); + + it('should return undefined for invalid URLs', async () => { + const result = await convertImageToBase64('invalid-url'); + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid URL format'), + expect.anything(), + ); + }); + + it('should return undefined for null or empty URLs', async () => { + // @ts-ignore - Testing invalid input + const result1 = await convertImageToBase64(null); + const result2 = await convertImageToBase64(''); + + expect(result1).toBeUndefined(); + expect(result2).toBeUndefined(); + expect(logger.error).toHaveBeenCalledTimes(2); + }); + + it('should handle conversion errors gracefully', async () => { + vi.mocked(imageToBase64).mockRejectedValue( + new Error('Conversion failed'), + ); + + const result = await convertImageToBase64( + 'https://raw.githubusercontent.com/github/explore/80688e429a7d4ef2fca1e82350fe8e3517d3494d/topics/nodejs/nodejs.png', + ); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error converting image to base64'), + expect.anything(), + ); + }); + + it('should handle timeouts properly', async () => { + // Mock a delayed response that would trigger the timeout + vi.mocked(imageToBase64).mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => resolve('delayed response'), 50); + }); + }); + + const result = await convertImageToBase64( + 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y', + { timeout: 10 }, + ); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Image conversion timed out'), + expect.anything(), + ); + }); + + it('should use default MIME type for unknown extensions', async () => { + const noExtensionUrl = 'https://github.githubassets.com/favicons/favicon'; + const result = await convertImageToBase64(noExtensionUrl); + expect(result).toBe('data:image/png;base64,mockBase64Data'); + }); + }); }); From 7c9b4af6c10c9cd86515718010657aaa6957c52c Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sun, 23 Mar 2025 00:35:36 +0100 Subject: [PATCH 12/12] Enhance Codecov configuration to require bundle changes and set a threshold for bundle size increase --- codecov.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codecov.yml b/codecov.yml index 4632b00..288460f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -20,3 +20,5 @@ comment: require_changes: true layout: 'condensed_header, condensed_files, condensed_footer' hide_project_coverage: true + require_bundle_changes: 'bundle_increase' + bundle_change_threshold: '1Mb'