Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 108 additions & 0 deletions src/chart/components/bubbles.ts
Original file line number Diff line number Diff line change
@@ -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<BubbleData>[],
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<BubbleData>,
index: number,
chartOptions: BubbleChartOptions,
): Promise<string> {
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 = `<g class="bubble-${index}" transform="translate(${node.x},${node.y})" data-language="${language}">`;

bubble += generateEllipses(radius);
bubble += generateCircleBase(radius, color);
bubble += await generateIconOrText(iconUrl, language, radius, color);
bubble += generateValueText(chartOptions, value, radius);

bubble += '</g>'; // 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 `
<ellipse rx="${radius * 0.6}" ry="${radius * 0.3}" cx="0" cy="${radius * -0.6}" fill="url(#grad--spot)" transform="rotate(-45)" class="shape"></ellipse>
<ellipse rx="${radius * 0.4}" ry="${radius * 0.2}" cx="0" cy="${radius * -0.7}" fill="url(#grad--spot)" transform="rotate(-225)" class="shape"></ellipse>
`;
}

function generateCircleBase(radius: number, color: string): string {
return `
<circle r="${radius}" cx="0" cy="0" fill="${color}" mask="url(#mask--light-bottom)" class="shape"></circle>
<circle r="${radius}" cx="0" cy="0" fill="lightblue" mask="url(#mask--light-top)" class="shape"></circle>
`;
}

async function generateIconOrText(
iconUrl: string,
language: string,
radius: number,
color: string,
): Promise<string> {
if (iconUrl) {
return `<image class="b-icon" href="${iconUrl}" width="${radius}" height="${radius}" x="${-radius / 2}" y="${-radius / 2}"></image>`;
} 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 += `
<tspan x="0" dy="${i === 0 ? 0 - adjustPos : lineHeight + adjustPos}">${line}</tspan>
`;
});
} else {
displayedText = language;
}

return `<text class="b-text" dy=".3em" style="font-size: ${fontSize}; text-shadow: 0 0 5px ${color};">${displayedText}</text>`;
}
}

function generateValueText(
chartOptions: BubbleChartOptions,
value: string | number,
radius: number,
): string {
if (
chartOptions.displayValues === 'all' ||
chartOptions.displayValues === 'bubbles'
) {
return `<text class="b-value" dy="3.5em" style="font-size: ${radius / 4}px;">${value}</text>`;
}
return '';
}
128 changes: 128 additions & 0 deletions src/chart/components/legend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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';

const legendItemHeight = 20;
const legendYPadding = 10;
const legendItemXPadding = 35;
const legendCircleRadius = 8;

export async function createLegend(
data: BubbleData[],
svgWidth: number,
maxBubbleY: number,
distanceFromBubbleChart: number,
chartOptions: BubbleChartOptions,
): Promise<{ svgLegend: string; legendHeight: number }> {
try {
const legendItems = await prepareLegendItems(data, chartOptions);
const rowItems = groupLegendItemsIntoRows(legendItems, svgWidth);
const svgLegend = generateSVGForLegendRows(
rowItems,
svgWidth,
maxBubbleY,
distanceFromBubbleChart,
chartOptions,
);

const legendHeight = calculateLegendHeight(rowItems);

return { svgLegend, legendHeight };
} catch (error) {
throw new GeneratorError(
'Failed to create legend.',
error instanceof Error ? error : undefined,
);
}
}

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'
? chartOptions.usePercentages
? ` (${item.value}%)`
: ` (${item.value})`
: '';
const text = `${item.name}${value}`;
const textWidth = await measureTextWidth(text, legendTextSize);
return {
text,
width: textWidth + legendCircleRadius * 2 + legendItemXPadding, // Include circle and padding
color: item.color,
};
}),
);
}

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;

legendItems.forEach((item) => {
if (currentRowWidth + item.width > svgWidth) {
currentRowIndex++;
rowItems[currentRowIndex] = [];
currentRowWidth = 0;
}
rowItems[currentRowIndex].push(item);
currentRowWidth += item.width;
});

return rowItems;
}

function generateSVGForLegendRows(
rowItems: { text: string; width: number; color: string }[][],
svgWidth: number,
maxBubbleY: number,
distanceFromBubbleChart: number,
chartOptions: BubbleChartOptions,
): string {
let svgLegend = `<g class="legend" transform="translate(${chartPadding}, ${maxBubbleY + distanceFromBubbleChart})">`;

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;
}

let animationDelay = rowIndex;
row.forEach((item, itemIndex) => {
animationDelay += itemIndex * 0.1;
svgLegend += `
<g transform="translate(${rowX}, ${rowY})" class="legend-item" style="animation-delay: ${animationDelay}s;">
<circle cx="10" cy="15" r="${legendCircleRadius}" fill="${item.color}" />
<text x="22" y="15">${item.text}</text>
</g>
`;
rowX += item.width;
});
rowY += legendItemHeight + legendYPadding;
});

svgLegend += '</g>';
return svgLegend;
}

function calculateLegendHeight(
rowItems: { text: string; width: number; color: string }[][],
): number {
return rowItems.length * (legendItemHeight + legendYPadding) + chartPadding;
}
123 changes: 123 additions & 0 deletions src/chart/components/title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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 = generateStyle(titleOptions);
const titleAlign = getAlignmentPosition(
titleOptions.textAnchor,
width,
chartPadding,
);
titleOptions.text = escapeSpecialChars(parseEmojis(titleOptions.text));

const textWidth = await measureTextWidth(
titleOptions.text,
titleOptions.fontSize,
titleOptions.fontWeight,
);

const { textElement, lines } = await generateTextElement(
titleOptions,
width,
titleHeight,
textWidth,
titleAlign,
);

return {
svgTitle: generateSVGTitle(
titleOptions,
titleAlign,
titleHeight,
style,
textElement,
),
titleLines: lines?.length || 1,
};
} catch (error) {
throw new GeneratorError(
'Failed to create title element.',
error instanceof Error ? error : undefined,
);
}
}

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 += `
<tspan x="${titleAlign}" dy="${index === 0 ? 0 : titleHeight}">${line}</tspan>
`;
});
} else {
textElement = titleOptions.text;
}

return { textElement, lines };
}

function generateSVGTitle(
titleOptions: TitleOptions,
titleAlign: number,
titleHeight: number,
style: string,
textElement: string,
): string {
return `
<text class="bc-title"
x="${titleAlign}"
y="${titleHeight + chartPadding}"
text-anchor="${titleOptions.textAnchor}"
style="${style.replaceAll('"', "'")}">
${textElement}
</text>
`;
}
Loading