Skip to content

Commit 78d2777

Browse files
authored
Merge pull request #16 from masonlet/feat(chart)/pie
feat(charts): add pie chart + optional segment stroke
2 parents dda8e02 + 2b71f8c commit 78d2777

24 files changed

Lines changed: 246 additions & 105 deletions

api/languages/index.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
1-
import { parseQueryParams } from '../../src/utils/params.js';
2-
import { fetchLanguageData, processLanguageData } from '../../src/api/github.js';
3-
import { generateChartData } from '../../src/render/chart.js';
4-
import { renderSvg } from '../../src/render/svg.js';
5-
import { renderError } from '../../src/render/error.js';
1+
import { parseQueryParams } from "../../src/utils/params.js";
2+
import { fetchLanguageData, processLanguageData } from "../../src/api/github.js";
3+
import { generateChartData } from "../../src/render/chart.js";
4+
import { renderSvg } from "../../src/render/svg.js";
5+
import { renderError } from "../../src/render/error.js";
66

77
export default async function handler(req, res) {
88
const params = parseQueryParams(req.query);
9-
const { width, height, selectedTheme, langCount, chartType, chartTitle, useTestData } = params;
9+
const { chartType, chartTitle, width, height, langCount, selectedTheme, stroke, useTestData } = params;
1010

1111
try {
1212
const rawData = await fetchLanguageData(useTestData);
1313
const normalizedData = processLanguageData(rawData, langCount);
1414

15-
const { segments, legend } = generateChartData(normalizedData, selectedTheme, chartType, width);
15+
const { segments, legend } = generateChartData(normalizedData, selectedTheme, chartType, width, stroke);
1616

1717
const svg = renderSvg(width, height, selectedTheme.bg, segments, legend, chartTitle, selectedTheme.text);
18-
res.setHeader('Content-Type', 'image/svg+xml');
19-
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=60');
18+
res.setHeader("Content-Type", "image/svg+xml");
19+
res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600, stale-while-revalidate=60");
2020
res.status(200).send(svg);
2121
} catch (error) {
2222
const errorSvg = renderError(error.message, width, height, selectedTheme);
23-
res.setHeader('Content-Type', 'image/svg+xml');
23+
res.setHeader("Content-Type", "image/svg+xml");
2424
res.status(500).send(errorSvg);
2525
}
2626
}

src/api/github.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { REFRESH_INTERVAL } from '../constants/config.js';
1+
import { REFRESH_INTERVAL } from "../constants/config.js";
22

33
let cachedLanguageData = null;
44
let lastRefresh = 0;
55

66
export async function fetchLanguageData(useTestData = false) {
77
if (useTestData) {
8-
const testData = await import ('../data/test-data.json', { with: { type: 'json' } });
8+
const testData = await import ("../data/test-data.json", { with: { type: "json" } });
99
return testData.default;
1010
}
1111

@@ -45,7 +45,7 @@ export async function fetchLanguageData(useTestData = false) {
4545

4646
export function processLanguageData(languageBytes, langCount){
4747
if(Object.keys(languageBytes).length === 0)
48-
throw new Error('No language data available');
48+
throw new Error("No language data available");
4949

5050
const totalBytes = Object.values(languageBytes).reduce((a, b) => a + b, 0);
5151

@@ -65,4 +65,4 @@ export function processLanguageData(languageBytes, langCount){
6565
export function resetCache() {
6666
cachedLanguageData = null;
6767
lastRefresh = 0;
68-
}
68+
}

src/charts/donut.js

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
1-
import { createDonutSegments } from './geometry.js';
2-
import { DONUT_GEOMETRY } from '../constants/geometry.js';
3-
import { createLegend } from './legend.js';
1+
import { createDonutSegments } from "./geometry.js";
2+
import { DONUT_GEOMETRY } from "../constants/geometry.js";
3+
import { createLegend } from "./legend.js";
44
import {
55
LEGEND_SHIFT_THRESHOLD,
66
LEGEND_STYLES
7-
} from '../constants/styles.js';
7+
} from "../constants/styles.js";
88

99
function calculateDonutCenter(width, isShifted) {
1010
const legendWidth = isShifted
1111
? LEGEND_STYLES.COLUMN_WIDTH * 2
1212
: LEGEND_STYLES.WIDTH;
1313

1414
const availableSpace = width - legendWidth - DONUT_GEOMETRY.MARGIN_RIGHT;
15-
1615
return availableSpace / 2;
1716
}
1817

@@ -24,7 +23,7 @@ function calculateLegendStartX(chartCenterX, donutRadius, isShifted) {
2423
return chartCenterX + donutRadius + DONUT_GEOMETRY.MARGIN_RIGHT;
2524
}
2625

27-
export function generateDonutChart(normalizedLanguages, selectedTheme, width) {
26+
export function generateDonutChart(normalizedLanguages, selectedTheme, width, stroke) {
2827
const isShifted = normalizedLanguages.length > LEGEND_SHIFT_THRESHOLD;
2928
const chartX = calculateDonutCenter(width, isShifted);
3029
const legendStartX = calculateLegendStartX(chartX, DONUT_GEOMETRY.OUTER_RADIUS, isShifted);
@@ -33,7 +32,8 @@ export function generateDonutChart(normalizedLanguages, selectedTheme, width) {
3332
normalizedLanguages,
3433
chartX,
3534
DONUT_GEOMETRY,
36-
selectedTheme.colours
35+
selectedTheme.colours,
36+
stroke
3737
);
3838

3939
const legend = createLegend(
@@ -45,4 +45,3 @@ export function generateDonutChart(normalizedLanguages, selectedTheme, width) {
4545

4646
return { segments, legend };
4747
}
48-

src/charts/geometry.js

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FULL_CIRCLE_ANGLE } from '../constants/geometry.js';
1+
import { FULL_CIRCLE_ANGLE } from "../constants/geometry.js";
22

33
export const polarToCartesian = (cx, cy, r, angleDeg) => {
44
const angleRad = (angleDeg - 90) * Math.PI / 180;
@@ -11,9 +11,19 @@ export const polarToCartesian = (cx, cy, r, angleDeg) => {
1111
export const describeSegment = (cx, cy, innerR, outerR, startAngle, endAngle) => {
1212
const startOuter = polarToCartesian(cx, cy, outerR, endAngle);
1313
const endOuter = polarToCartesian(cx, cy, outerR, startAngle);
14+
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
15+
16+
if (innerR === 0) {
17+
return `
18+
M ${cx} ${cy}
19+
L ${startOuter.x} ${startOuter.y}
20+
A ${outerR} ${outerR} 0 ${largeArcFlag} 0 ${endOuter.x} ${endOuter.y}
21+
Z
22+
`.trim();
23+
}
24+
1425
const startInner = polarToCartesian(cx, cy, innerR, startAngle);
1526
const endInner = polarToCartesian(cx, cy, innerR, endAngle);
16-
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
1727

1828
return `
1929
M ${startOuter.x} ${startOuter.y}
@@ -24,15 +34,13 @@ export const describeSegment = (cx, cy, innerR, outerR, startAngle, endAngle) =>
2434
`.trim();
2535
};
2636

27-
export const createDonutSegments = (languages, cx, geometry, colours) => {
28-
let currentAngle = 0;
37+
export const createDonutSegments = (languages, cx, geometry, colours, stroke) => {
38+
let currentAngle = -0.1;
2939

3040
return languages.map((lang, i) => {
31-
const isLast = i === languages.length - 1;
32-
let angle = isLast ? 360 - currentAngle : (lang.pct / 100) * 360;
33-
34-
const segmentAngle = Math.min(currentAngle + angle, FULL_CIRCLE_ANGLE);
41+
let angle = (lang.pct / 100) * 360;
3542

43+
const segmentAngle = Math.min(currentAngle + angle + 0.1, FULL_CIRCLE_ANGLE);
3644
const path = describeSegment(
3745
cx,
3846
geometry.CENTER_Y,
@@ -41,8 +49,12 @@ export const createDonutSegments = (languages, cx, geometry, colours) => {
4149
currentAngle,
4250
segmentAngle
4351
);
44-
52+
4553
currentAngle += angle;
46-
return `<path d="${path}" fill="${colours[i]}"/>`;
54+
const fillColour = colours[i % colours.length];
55+
const strokeAttr = stroke
56+
? ` stroke="#000" stroke-width="0.5" stroke-linejoin="round"`
57+
: ` stroke="${fillColour}" stroke-width="0.2"`;
58+
return `<path d="${path}" fill="${fillColour}"${strokeAttr} shape-rendering="geometricPrecision"/>`;
4759
}).join('');
4860
}

src/charts/legend.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { LEGEND_STYLES } from '../constants/styles.js';
1+
import { LEGEND_STYLES } from "../constants/styles.js";
22

33
export function createLegend(languages, isShifted, selectedTheme, legendStartX) {
44
const numLangs = languages.length;
@@ -25,4 +25,4 @@ export function createLegend(languages, isShifted, selectedTheme, legendStartX)
2525
</text>
2626
`;
2727
}).join('');
28-
}
28+
}

src/charts/pie.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { createDonutSegments } from "./geometry.js";
2+
import { DONUT_GEOMETRY } from "../constants/geometry.js";
3+
import { createLegend } from "./legend.js";
4+
import {
5+
LEGEND_SHIFT_THRESHOLD,
6+
LEGEND_STYLES
7+
} from "../constants/styles.js";
8+
9+
function calculatePieCenter(width, isShifted) {
10+
const legendWidth = isShifted
11+
? LEGEND_STYLES.COLUMN_WIDTH * 2
12+
: LEGEND_STYLES.WIDTH;
13+
14+
const availableSpace = width - legendWidth - DONUT_GEOMETRY.MARGIN_RIGHT;
15+
return availableSpace / 2;
16+
}
17+
18+
function calculateLegendStartX(chartCenterX, pieRadius) {
19+
return chartCenterX + pieRadius + DONUT_GEOMETRY.MARGIN_RIGHT;
20+
}
21+
22+
export function generatePieChart(normalizedLanguages, selectedTheme, width, stroke = true) {
23+
const isShifted = normalizedLanguages.length > LEGEND_SHIFT_THRESHOLD;
24+
const chartX = calculatePieCenter(width, isShifted);
25+
const legendStartX = calculateLegendStartX(chartX, DONUT_GEOMETRY.OUTER_RADIUS);
26+
27+
const pieGeometry = { ...DONUT_GEOMETRY, INNER_RADIUS: 0 };
28+
29+
const segments = createDonutSegments(
30+
normalizedLanguages,
31+
chartX,
32+
pieGeometry,
33+
selectedTheme.colours,
34+
stroke
35+
);
36+
37+
const legend = createLegend(
38+
normalizedLanguages,
39+
isShifted,
40+
selectedTheme,
41+
legendStartX
42+
);
43+
44+
return { segments, legend };
45+
}

src/constants/config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
export const DEFAULT_CONFIG = {
2-
TITLE: 'Top Languages',
2+
TITLE: "Top Languages",
33
WIDTH: 400,
44
HEIGHT: 300,
55
COUNT: 8,
66
MIN_WIDTH: 350
77
}
88

99
export const REFRESH_INTERVAL = 1000 * 60 * 60;
10-
export const MAX_COUNT = 16;
10+
export const MAX_COUNT = 16;

src/constants/styles.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const LEGEND_STYLES = {
1616
export const ERROR_STYLES = {
1717
TEXT_Y: 100,
1818
FONT_SIZE: 18,
19-
COLOUR: '#ff6b6b'
19+
COLOUR: "#ff6b6b"
2020
}
2121

22-
export const LEGEND_SHIFT_THRESHOLD = 8;
22+
export const LEGEND_SHIFT_THRESHOLD = 8;

src/constants/themes.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
export const THEMES = {
22
default: {
3-
bg: '#0d1117',
4-
text: '#ffffff',
5-
colours: ['#A8D5Ba', '#FFD6A5', '#FFAAA6', '#D0CFCF', '#CBAACB', '#FFE156', '#96D5E9', '#F3B0C3']
3+
bg: "#0d1117",
4+
text: "#ffffff",
5+
colours: ["#A8D5Ba", "#FFD6A5", "#FFAAA6", "#D0CFCF", "#CBAACB", "#FFE156", "#96D5E9", "#F3B0C3"]
66
},
77
light: {
8-
bg: '#ffffff',
9-
text: '#2f2f2f',
10-
colours: ['#2ecc71', '#3498db', '#e74c3c', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e']
8+
bg: "#ffffff",
9+
text: "#2f2f2f",
10+
colours: ["#2ecc71", "#3498db", "#e74c3c", "#f39c12", "#9b59b6", "#1abc9c", "#e67e22", "#34495e"]
1111
},
1212
dark: {
13-
bg: '#1a1a1a',
14-
text: '#ccd6f6',
15-
colours: ['#ff6b6b', '#4ecdc3', '#45b7d1', '#ffa07a', '#98d8c8', '#f7dc6f', '#bb8fce', '#85c1e2']
13+
bg: "#1a1a1a",
14+
text: "#ccd6f6",
15+
colours: ["#ff6b6b", "#4ecdc3", "#45b7d1", "#ffa07a", "#98d8c8", "#f7dc6f", "#bb8fce", "#85c1e2"]
1616
}
17-
};
17+
};

src/constants/types.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const VALID_TYPES = ['donut'];
1+
export const VALID_TYPES = ["donut", "pie"];

0 commit comments

Comments
 (0)