From e01d8bd472b67476cc22dad424c80f603c3023a4 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 22 Feb 2025 00:35:44 +0100 Subject: [PATCH 1/6] 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 4f896ff084e956c8f520b6bc4069fa345a1381b3 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 22 Feb 2025 13:22:47 +0100 Subject: [PATCH 2/6] Update @octokit/rest and related dependencies to latest versions --- package.json | 2 +- yarn.lock | 59 ++++++++++++++++++++++++++++++++++------------------ 2 files changed, 40 insertions(+), 21 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 9b15237..91851c5 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.6.2" 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" From 8095101b04fa0c8b04d99044a34d1ad1dffee10b Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sun, 23 Feb 2025 00:56:13 +0100 Subject: [PATCH 3/6] 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 fcbdc60948efd44d073044864dbdf197931e7cb1 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sun, 23 Feb 2025 17:28:07 +0100 Subject: [PATCH 4/6] 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 1494029ee5e4ac25df20c727398783481ed0ebb6 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 1 Mar 2025 16:47:35 +0100 Subject: [PATCH 5/6] 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 7f8dddaa6a1d04c072fb85f037f6c3a3054af130 Mon Sep 17 00:00:00 2001 From: Matteo Ciapparelli Date: Sat, 1 Mar 2025 16:48:16 +0100 Subject: [PATCH 6/6] 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};