@gh-top-languages/lib 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -0
  3. package/dist/charts/donut.d.ts +3 -0
  4. package/dist/charts/donut.d.ts.map +1 -0
  5. package/dist/charts/donut.js +13 -0
  6. package/dist/charts/donut.js.map +1 -0
  7. package/dist/charts/geometry.d.ts +5 -0
  8. package/dist/charts/geometry.d.ts.map +1 -0
  9. package/dist/charts/geometry.js +52 -0
  10. package/dist/charts/geometry.js.map +1 -0
  11. package/dist/charts/layout.d.ts +7 -0
  12. package/dist/charts/layout.d.ts.map +1 -0
  13. package/dist/charts/layout.js +17 -0
  14. package/dist/charts/layout.js.map +1 -0
  15. package/dist/charts/legend.d.ts +3 -0
  16. package/dist/charts/legend.d.ts.map +1 -0
  17. package/dist/charts/legend.js +42 -0
  18. package/dist/charts/legend.js.map +1 -0
  19. package/dist/charts/pie.d.ts +3 -0
  20. package/dist/charts/pie.d.ts.map +1 -0
  21. package/dist/charts/pie.js +13 -0
  22. package/dist/charts/pie.js.map +1 -0
  23. package/dist/constants/config.d.ts +11 -0
  24. package/dist/constants/config.d.ts.map +1 -0
  25. package/dist/constants/config.js +11 -0
  26. package/dist/constants/config.js.map +1 -0
  27. package/dist/constants/geometry.d.ts +12 -0
  28. package/dist/constants/geometry.d.ts.map +1 -0
  29. package/dist/constants/geometry.js +8 -0
  30. package/dist/constants/geometry.js.map +1 -0
  31. package/dist/constants/styles.d.ts +21 -0
  32. package/dist/constants/styles.d.ts.map +1 -0
  33. package/dist/constants/styles.js +21 -0
  34. package/dist/constants/styles.js.map +1 -0
  35. package/dist/constants/themes.d.ts +18 -0
  36. package/dist/constants/themes.d.ts.map +1 -0
  37. package/dist/constants/themes.js +69 -0
  38. package/dist/constants/themes.js.map +1 -0
  39. package/dist/constants/types.d.ts +2 -0
  40. package/dist/constants/types.d.ts.map +1 -0
  41. package/dist/constants/types.js +5 -0
  42. package/dist/constants/types.js.map +1 -0
  43. package/dist/render/chart.d.ts +3 -0
  44. package/dist/render/chart.d.ts.map +1 -0
  45. package/dist/render/chart.js +11 -0
  46. package/dist/render/chart.js.map +1 -0
  47. package/dist/render/error.d.ts +3 -0
  48. package/dist/render/error.d.ts.map +1 -0
  49. package/dist/render/error.js +19 -0
  50. package/dist/render/error.js.map +1 -0
  51. package/dist/render/svg.d.ts +2 -0
  52. package/dist/render/svg.d.ts.map +1 -0
  53. package/dist/render/svg.js +22 -0
  54. package/dist/render/svg.js.map +1 -0
  55. package/dist/types.d.ts +24 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +2 -0
  58. package/dist/types.js.map +1 -0
  59. package/dist/utils/params.d.ts +18 -0
  60. package/dist/utils/params.d.ts.map +1 -0
  61. package/dist/utils/params.js +37 -0
  62. package/dist/utils/params.js.map +1 -0
  63. package/dist/utils/sanitize.d.ts +2 -0
  64. package/dist/utils/sanitize.d.ts.map +1 -0
  65. package/dist/utils/sanitize.js +13 -0
  66. package/dist/utils/sanitize.js.map +1 -0
  67. package/package.json +48 -0
  68. package/src/charts/donut.ts +22 -0
  69. package/src/charts/geometry.ts +88 -0
  70. package/src/charts/layout.ts +19 -0
  71. package/src/charts/legend.ts +53 -0
  72. package/src/charts/pie.ts +22 -0
  73. package/src/constants/config.ts +11 -0
  74. package/src/constants/geometry.ts +9 -0
  75. package/src/constants/styles.ts +23 -0
  76. package/src/constants/themes.ts +68 -0
  77. package/src/constants/types.ts +4 -0
  78. package/src/render/chart.ts +24 -0
  79. package/src/render/error.ts +25 -0
  80. package/src/render/svg.ts +31 -0
  81. package/src/types.ts +28 -0
  82. package/src/utils/params.ts +47 -0
  83. package/src/utils/sanitize.ts +14 -0
@@ -0,0 +1,24 @@
1
+ export type Point = {
2
+ x: number;
3
+ y: number;
4
+ };
5
+ export type Language = {
6
+ lang: string;
7
+ pct: number;
8
+ };
9
+ export type Geometry = {
10
+ CENTER_Y: number;
11
+ INNER_RADIUS: number;
12
+ OUTER_RADIUS: number;
13
+ };
14
+ export type ChartType = "donut" | "pie";
15
+ export type ChartResult = {
16
+ segments: string;
17
+ legend: string;
18
+ };
19
+ export type Theme = {
20
+ readonly colours: readonly string[];
21
+ readonly text: string;
22
+ readonly bg: string;
23
+ };
24
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,KAAK,GAAG;IAClB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAG,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,EAAM,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,KAAK,CAAC;AAExC,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAI,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,KAAK,GAAG;IAClB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAK,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,EAAO,MAAM,CAAC;CAC1B,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,18 @@
1
+ import type { ChartType } from "../types.js";
2
+ export type QueryParams = Record<string, string | undefined>;
3
+ export declare function parseQueryParams(query: QueryParams): {
4
+ chartType: ChartType;
5
+ chartTitle: string;
6
+ width: number;
7
+ height: number;
8
+ count: number;
9
+ selectedTheme: {
10
+ bg: "#0d1117" | "#ffffff" | "#1a1a1a";
11
+ text: string;
12
+ colours: string[];
13
+ };
14
+ stroke: boolean;
15
+ useTestData: boolean;
16
+ errorTest: string;
17
+ };
18
+ //# sourceMappingURL=params.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"params.d.ts","sourceRoot":"","sources":["../../src/utils/params.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAM7C,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAY7D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,WAAW;;;;;;;;;;;;;;EA4BlD"}
@@ -0,0 +1,37 @@
1
+ import { sanitize } from "./sanitize.js";
2
+ import { VALID_TYPES } from "../constants/types.js";
3
+ import { DEFAULT_CONFIG } from "../constants/config.js";
4
+ import { THEMES } from "../constants/themes.js";
5
+ const parseIntSafe = (val, fallback) => {
6
+ const parsed = Number.parseInt(val ?? '', 10);
7
+ return Number.isNaN(parsed) ? fallback : parsed;
8
+ };
9
+ const normalizeHex = (val) => `#${val.replace(/^#/, '')}`;
10
+ export function parseQueryParams(query) {
11
+ const baseTheme = THEMES[query["theme"]] ?? THEMES.default;
12
+ const count = parseIntSafe(query["count"], DEFAULT_CONFIG.COUNT);
13
+ const customColours = [...baseTheme.colours];
14
+ for (let i = 1; i <= DEFAULT_CONFIG.MAX_COUNT; i++) {
15
+ const colourVal = query[`c${i}`];
16
+ if (colourVal)
17
+ customColours[i - 1] = normalizeHex(colourVal);
18
+ }
19
+ const typeParam = query["type"];
20
+ const chartType = VALID_TYPES.some(t => t === typeParam) ? typeParam : "donut";
21
+ return {
22
+ chartType,
23
+ chartTitle: query["hide_title"] === "true" ? '' : sanitize(query["title"] ?? DEFAULT_CONFIG.TITLE),
24
+ width: Math.max(parseIntSafe(query["width"], DEFAULT_CONFIG.WIDTH), DEFAULT_CONFIG.MIN_WIDTH),
25
+ height: Math.max(parseIntSafe(query["height"], DEFAULT_CONFIG.HEIGHT), DEFAULT_CONFIG.MIN_HEIGHT),
26
+ count: Math.min(Math.max(count, 1), DEFAULT_CONFIG.MAX_COUNT),
27
+ selectedTheme: {
28
+ bg: THEMES[query["bg"]]?.bg ?? (query["bg"] ? normalizeHex(query["bg"]) : baseTheme.bg),
29
+ text: query["text"] ? normalizeHex(query["text"]) : baseTheme.text,
30
+ colours: customColours
31
+ },
32
+ stroke: query["stroke"] === "true",
33
+ useTestData: query["test"] === "true",
34
+ errorTest: sanitize(query["error"] ?? '')
35
+ };
36
+ }
37
+ //# sourceMappingURL=params.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"params.js","sourceRoot":"","sources":["../../src/utils/params.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export declare const sanitize: (str: unknown) => string;
2
+ //# sourceMappingURL=sanitize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../../src/utils/sanitize.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,QAAQ,GAAI,KAAK,OAAO,KAAG,MAKvC,CAAC"}
@@ -0,0 +1,13 @@
1
+ const ESCAPE_MAP = {
2
+ '<': '&lt;',
3
+ '>': '&gt;',
4
+ '&': '&amp;',
5
+ '"': '&quot;',
6
+ "'": '&#39;',
7
+ };
8
+ export const sanitize = (str) => {
9
+ if (typeof str !== "string")
10
+ return '';
11
+ return str.replace(/[<>&"']/g, (m) => ESCAPE_MAP[m]);
12
+ };
13
+ //# sourceMappingURL=sanitize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../../src/utils/sanitize.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,GAAG;IACjB,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,OAAO;IACZ,GAAG,EAAE,QAAQ;IACb,GAAG,EAAE,OAAO;CACJ,CAAC;AAEX,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,GAAY,EAAU,EAAE;IAC/C,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,EAAE,CAAC;IACvC,OAAO,GAAG,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,CAAS,EAAU,EAAE,CACnD,UAAU,CAAC,CAA4B,CAAC,CACzC,CAAC;AACJ,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@gh-top-languages/lib",
3
+ "description": "Library for github-top-languages — chart generation, SVG output, and parameter parsing",
4
+ "author": "Mason L'Etoile",
5
+ "version": "1.0.3",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/gh-top-languages/lib.git"
10
+ },
11
+ "homepage": "https://github.com/gh-top-languages/lib#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/gh-top-languages/lib/issues"
14
+ },
15
+ "keywords": ["svg", "charts", "github-profile", "github-top-languages"],
16
+ "type": "module",
17
+ "scripts": {
18
+ "test": "vitest",
19
+ "test:coverage": "vitest --coverage",
20
+ "typecheck": "tsc --noEmit",
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "prepare": "tsc -p tsconfig.build.json"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "~6.0.2",
26
+ "vitest": "^4.0.17",
27
+ "@vitest/coverage-v8": "^4.0.17"
28
+ },
29
+ "files": ["dist", "src"],
30
+ "exports": {
31
+ "./types.js": { "types": "./dist/types.d.ts", "import": "./dist/types.js" },
32
+ "./charts/donut.js": { "types": "./dist/charts/donut.d.ts", "import": "./dist/charts/donut.js" },
33
+ "./charts/pie.js": { "types": "./dist/charts/pie.d.ts", "import": "./dist/charts/pie.js" },
34
+ "./charts/geometry.js": { "types": "./dist/charts/geometry.d.ts", "import": "./dist/charts/geometry.js" },
35
+ "./charts/legend.js": { "types": "./dist/charts/legend.d.ts", "import": "./dist/charts/legend.js" },
36
+ "./charts/layout.js": { "types": "./dist/charts/layout.d.ts", "import": "./dist/charts/layout.js" },
37
+ "./render/chart.js": { "types": "./dist/render/chart.d.ts", "import": "./dist/render/chart.js" },
38
+ "./render/svg.js": { "types": "./dist/render/svg.d.ts", "import": "./dist/render/svg.js" },
39
+ "./render/error.js": { "types": "./dist/render/error.d.ts", "import": "./dist/render/error.js" },
40
+ "./utils/params.js": { "types": "./dist/utils/params.d.ts", "import": "./dist/utils/params.js" },
41
+ "./utils/sanitize.js": { "types": "./dist/utils/sanitize.d.ts", "import": "./dist/utils/sanitize.js" },
42
+ "./constants/config.js": { "types": "./dist/constants/config.d.ts", "import": "./dist/constants/config.js" },
43
+ "./constants/geometry.js": { "types": "./dist/constants/geometry.d.ts", "import": "./dist/constants/geometry.js" },
44
+ "./constants/styles.js": { "types": "./dist/constants/styles.d.ts", "import": "./dist/constants/styles.js" },
45
+ "./constants/themes.js": { "types": "./dist/constants/themes.d.ts", "import": "./dist/constants/themes.js" },
46
+ "./constants/types.js": { "types": "./dist/constants/types.d.ts", "import": "./dist/constants/types.js" }
47
+ }
48
+ }
@@ -0,0 +1,22 @@
1
+ import type { Language, Theme, ChartResult } from "../types.js";
2
+ import { resolveLayout, calculateChartCenter, calculateLegendStartX } from "./layout.js";
3
+ import { DONUT_GEOMETRY } from "../constants/geometry.js";
4
+ import { createDonutSegments } from "./geometry.js";
5
+ import { createLegend } from "./legend.js";
6
+
7
+ export function generateDonutChart(
8
+ normalizedLanguages: Language[],
9
+ selectedTheme: Theme,
10
+ width: number,
11
+ stroke: boolean
12
+ ): ChartResult {
13
+ const { isShifted, useStroke } = resolveLayout(normalizedLanguages.length, stroke);
14
+
15
+ const chartX = calculateChartCenter(width, isShifted);
16
+ const legendStartX = calculateLegendStartX(chartX, DONUT_GEOMETRY.OUTER_RADIUS);
17
+
18
+ const segments = createDonutSegments(normalizedLanguages, chartX, DONUT_GEOMETRY, [...selectedTheme.colours], useStroke);
19
+ const legend = createLegend(normalizedLanguages, isShifted, selectedTheme, legendStartX, useStroke);
20
+
21
+ return { segments, legend };
22
+ }
@@ -0,0 +1,88 @@
1
+ import type { Point, Language, Geometry } from "../types.js"
2
+ import { FULL_CIRCLE_ANGLE } from "../constants/geometry.js";
3
+
4
+ export const polarToCartesian = (
5
+ cx: number,
6
+ cy: number,
7
+ r: number,
8
+ angleDeg: number
9
+ ): Point => {
10
+ const angleRad = (angleDeg - 90) * Math.PI / 180;
11
+ return {
12
+ x: cx + (r * Math.cos(angleRad)),
13
+ y: cy + (r * Math.sin(angleRad))
14
+ };
15
+ };
16
+
17
+ export const describeSegment = (
18
+ cx: number,
19
+ cy: number,
20
+ innerR: number,
21
+ outerR: number,
22
+ startAngle: number,
23
+ endAngle: number
24
+ ): string => {
25
+ const angleDiff = endAngle - startAngle
26
+
27
+ if (angleDiff >= 360 || angleDiff <= -360) {
28
+ const midAngle = startAngle + 180;
29
+ const firstHalf = describeSegment(cx, cy, innerR, outerR, startAngle, midAngle);
30
+ const secondHalf = describeSegment(cx, cy, innerR, outerR, midAngle, endAngle);
31
+ return firstHalf + ' ' + secondHalf;
32
+ }
33
+
34
+ const startOuter = polarToCartesian(cx, cy, outerR, endAngle);
35
+ const endOuter = polarToCartesian(cx, cy, outerR, startAngle);
36
+ const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
37
+
38
+ if (innerR === 0) {
39
+ return `
40
+ M ${cx} ${cy}
41
+ L ${startOuter.x} ${startOuter.y}
42
+ A ${outerR} ${outerR} 0 ${largeArcFlag} 0 ${endOuter.x} ${endOuter.y}
43
+ Z
44
+ `.trim();
45
+ }
46
+
47
+ const startInner = polarToCartesian(cx, cy, innerR, startAngle);
48
+ const endInner = polarToCartesian(cx, cy, innerR, endAngle);
49
+
50
+ return `
51
+ M ${startOuter.x} ${startOuter.y}
52
+ A ${outerR} ${outerR} 0 ${largeArcFlag} 0 ${endOuter.x} ${endOuter.y}
53
+ L ${startInner.x} ${startInner.y}
54
+ A ${innerR} ${innerR} 0 ${largeArcFlag} 1 ${endInner.x} ${endInner.y}
55
+ Z
56
+ `.trim();
57
+ };
58
+
59
+ export const createDonutSegments = (
60
+ languages: Language[],
61
+ cx: number,
62
+ geometry: Geometry,
63
+ colours: string[],
64
+ stroke: boolean
65
+ ): string => {
66
+ let currentAngle = -0.1;
67
+
68
+ return languages.map((lang, i) => {
69
+ let angle = (lang.pct / 100) * 360;
70
+
71
+ const segmentAngle = Math.min(currentAngle + angle + 0.1, FULL_CIRCLE_ANGLE);
72
+ const path = describeSegment(
73
+ cx,
74
+ geometry.CENTER_Y,
75
+ geometry.INNER_RADIUS,
76
+ geometry.OUTER_RADIUS,
77
+ currentAngle,
78
+ segmentAngle
79
+ );
80
+
81
+ currentAngle += angle;
82
+ const fillColour = colours[i % colours.length];
83
+ const strokeAttr = stroke
84
+ ? ` stroke="#000" stroke-width="0.5" stroke-linejoin="round"`
85
+ : ` stroke="${fillColour}" stroke-width="0.2"`;
86
+ return `<path d="${path}" fill="${fillColour}"${strokeAttr} shape-rendering="geometricPrecision"/>`;
87
+ }).join('');
88
+ }
@@ -0,0 +1,19 @@
1
+ import { LEGEND_SHIFT_THRESHOLD, LEGEND_STYLES, CHART_MARGIN_RIGHT } from "../constants/styles.js";
2
+
3
+ export function resolveLayout(count: number, stroke: boolean) {
4
+ return {
5
+ isShifted: count > LEGEND_SHIFT_THRESHOLD,
6
+ useStroke: count > 1 ? stroke : false
7
+ };
8
+ }
9
+
10
+ export function calculateChartCenter(width: number, isShifted: boolean): number {
11
+ const legendWidth = isShifted
12
+ ? LEGEND_STYLES.COLUMN_WIDTH * 2
13
+ : LEGEND_STYLES.WIDTH;
14
+ return (width - legendWidth - CHART_MARGIN_RIGHT) / 2;
15
+ }
16
+
17
+ export function calculateLegendStartX(chartCenterX: number, radius: number): number {
18
+ return chartCenterX + radius + CHART_MARGIN_RIGHT;
19
+ }
@@ -0,0 +1,53 @@
1
+ import { LEGEND_STYLES } from "../constants/styles.js";
2
+ import type { Language, Theme } from "../types.js";
3
+
4
+ export function createLegend(
5
+ languages: Language[],
6
+ isShifted: boolean,
7
+ selectedTheme: Theme,
8
+ legendStartX: number,
9
+ stroke: boolean
10
+ ): string {
11
+ const numLangs = languages.length;
12
+
13
+ return languages.map((lang, i) => {
14
+ let x: number, y: number;
15
+
16
+ if (!isShifted) {
17
+ x = legendStartX;
18
+ y = LEGEND_STYLES.START_Y + i * LEGEND_STYLES.ROW_HEIGHT;
19
+ } else {
20
+ const half = Math.ceil(numLangs / 2);
21
+ const col = Math.floor(i / half);
22
+ const row = i % half;
23
+
24
+ x = legendStartX + col * LEGEND_STYLES.COLUMN_WIDTH;
25
+ y = LEGEND_STYLES.START_Y + row * LEGEND_STYLES.ROW_HEIGHT;
26
+ }
27
+
28
+ const fill = selectedTheme.colours[i];
29
+ const strokeAttr = stroke
30
+ ? ` stroke="#000" stroke-width="0.5" stroke-linejoin="round"`
31
+ : ``;
32
+
33
+ return `
34
+ <rect
35
+ x="${x}"
36
+ y="${y - LEGEND_STYLES.SQUARE_SIZE + 3}"
37
+ width="${LEGEND_STYLES.SQUARE_SIZE}"
38
+ height="${LEGEND_STYLES.SQUARE_SIZE}"
39
+ fill="${fill}"
40
+ rx="${LEGEND_STYLES.SQUARE_RADIUS}"${strokeAttr}
41
+ />
42
+ <text
43
+ x="${x + LEGEND_STYLES.SQUARE_SIZE + 5}"
44
+ y="${y}"
45
+ fill="${selectedTheme.text}"
46
+ font-size="${LEGEND_STYLES.FONT_SIZE}"
47
+ font-family="Arial"
48
+ >
49
+ ${lang.lang} ${lang.pct.toFixed(1)}%
50
+ </text>
51
+ `;
52
+ }).join('');
53
+ }
@@ -0,0 +1,22 @@
1
+ import type { Language, Theme, ChartResult } from "../types.js";
2
+ import { resolveLayout, calculateChartCenter, calculateLegendStartX } from "./layout.js";
3
+ import { PIE_GEOMETRY } from "../constants/geometry.js";
4
+ import { createDonutSegments } from "./geometry.js";
5
+ import { createLegend } from "./legend.js";
6
+
7
+ export function generatePieChart(
8
+ normalizedLanguages: Language[],
9
+ selectedTheme: Theme,
10
+ width: number,
11
+ stroke: boolean
12
+ ): ChartResult {
13
+ const { isShifted, useStroke } = resolveLayout(normalizedLanguages.length, stroke);
14
+
15
+ const chartX = calculateChartCenter(width, isShifted);
16
+ const legendStartX = calculateLegendStartX(chartX, PIE_GEOMETRY.OUTER_RADIUS);
17
+
18
+ const segments = createDonutSegments(normalizedLanguages, chartX, PIE_GEOMETRY, [...selectedTheme.colours], useStroke);
19
+ const legend = createLegend(normalizedLanguages, isShifted, selectedTheme, legendStartX, useStroke);
20
+
21
+ return { segments, legend };
22
+ }
@@ -0,0 +1,11 @@
1
+ export const DEFAULT_CONFIG = {
2
+ TITLE: "Top Languages",
3
+ WIDTH: 400,
4
+ MIN_WIDTH: 400,
5
+ HEIGHT: 300,
6
+ MIN_HEIGHT: 265,
7
+ COUNT: 8,
8
+ MAX_COUNT: 16
9
+ } as const;
10
+
11
+ export const REFRESH_INTERVAL = 1000 * 60 * 60;
@@ -0,0 +1,9 @@
1
+ export const FULL_CIRCLE_ANGLE = 359.9999;
2
+
3
+ const BASE_GEOMETRY = {
4
+ CENTER_Y: 170,
5
+ OUTER_RADIUS: 80,
6
+ } as const;
7
+
8
+ export const DONUT_GEOMETRY = { ...BASE_GEOMETRY, INNER_RADIUS: 50 } as const;
9
+ export const PIE_GEOMETRY = { ...BASE_GEOMETRY, INNER_RADIUS: 0 } as const;
@@ -0,0 +1,23 @@
1
+ export const TITLE_STYLES = {
2
+ TEXT_Y: 30,
3
+ FONT_SIZE: 24
4
+ } as const;
5
+
6
+ export const LEGEND_STYLES = {
7
+ START_Y: 80,
8
+ ROW_HEIGHT: 25,
9
+ SQUARE_SIZE: 12,
10
+ SQUARE_RADIUS: 2,
11
+ FONT_SIZE: 11,
12
+ WIDTH: 130,
13
+ COLUMN_WIDTH: 105
14
+ } as const;
15
+
16
+ export const ERROR_STYLES = {
17
+ TEXT_Y: 100,
18
+ FONT_SIZE: 18,
19
+ COLOUR: "#ff6b6b"
20
+ } as const;
21
+
22
+ export const LEGEND_SHIFT_THRESHOLD = 8;
23
+ export const CHART_MARGIN_RIGHT = 20;
@@ -0,0 +1,68 @@
1
+ export const THEMES = {
2
+ default: {
3
+ bg: "#0d1117",
4
+ text: "#ffffff",
5
+ colours: [
6
+ "#A8D5Ba",
7
+ "#FFD6A5",
8
+ "#FFAAA6",
9
+ "#D0CFCF",
10
+ "#CBAACB",
11
+ "#FFE156",
12
+ "#96D5E9",
13
+ "#F3B0C3",
14
+ "#B4A7D6",
15
+ "#FFB6B9",
16
+ "#A3E4D7",
17
+ "#F8B88B",
18
+ "#C9E4CA",
19
+ "#FAD7A0",
20
+ "#AED6F1",
21
+ "#D7BDE2"
22
+ ]
23
+ },
24
+ light: {
25
+ bg: "#ffffff",
26
+ text: "#2f2f2f",
27
+ colours: [
28
+ "#2ecc71",
29
+ "#3498db",
30
+ "#e74c3c",
31
+ "#f39c12",
32
+ "#9b59b6",
33
+ "#1abc9c",
34
+ "#e67e22",
35
+ "#34495e",
36
+ "#16a085",
37
+ "#c0392b",
38
+ "#8e44ad",
39
+ "#27ae60",
40
+ "#d35400",
41
+ "#2980b9",
42
+ "#7f8c8d",
43
+ "#f1c40f"
44
+ ]
45
+ },
46
+ dark: {
47
+ bg: "#1a1a1a",
48
+ text: "#ccd6f6",
49
+ colours: [
50
+ "#ff6b6b",
51
+ "#4ecdc3",
52
+ "#45b7d1",
53
+ "#ffa07a",
54
+ "#98d8c8",
55
+ "#f7dc6f",
56
+ "#bb8fce",
57
+ "#64ffda",
58
+ "#85c1e2",
59
+ "#ff8a80",
60
+ "#a7ffeb",
61
+ "#ffd54f",
62
+ "#ea80fc",
63
+ "#80d8ff",
64
+ "#ffab91",
65
+ "#b9f6ca"
66
+ ]
67
+ }
68
+ } as const;
@@ -0,0 +1,4 @@
1
+ export const VALID_TYPES = [
2
+ "donut",
3
+ "pie"
4
+ ] as const;
@@ -0,0 +1,24 @@
1
+ import type { Language, Theme, ChartType, ChartResult } from "../types.js";
2
+ import { generateDonutChart } from "../charts/donut.js";
3
+ import { generatePieChart } from "../charts/pie.js";
4
+
5
+ const CHART_GENERATORS: Record<ChartType, (
6
+ data: Language[],
7
+ theme: Theme,
8
+ width: number,
9
+ stroke: boolean
10
+ ) => ChartResult> = {
11
+ donut: generateDonutChart,
12
+ pie: generatePieChart,
13
+ }
14
+
15
+ export function generateChartData(
16
+ data: Language[],
17
+ theme: Theme,
18
+ chartType: ChartType,
19
+ width: number,
20
+ stroke: boolean
21
+ ): ChartResult {
22
+ const generator = CHART_GENERATORS[chartType] || CHART_GENERATORS.donut;
23
+ return generator(data, theme, width, stroke);
24
+ }
@@ -0,0 +1,25 @@
1
+ import type { Theme } from "../types.js";
2
+ import { THEMES } from "../constants/themes.js";
3
+ import { ERROR_STYLES } from "../constants/styles.js"
4
+ import { sanitize } from "../utils/sanitize.js";
5
+
6
+ export function renderError(
7
+ message: string,
8
+ width: number,
9
+ height: number,
10
+ selectedTheme?: Theme
11
+ ): string {
12
+ const background = selectedTheme?.bg || THEMES.default.bg;
13
+ const maxLen = 40;
14
+ const truncated = message.length > maxLen
15
+ ? sanitize(message.slice(0, maxLen)) + "..."
16
+ : sanitize(message);
17
+ return `
18
+ <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
19
+ <rect width="${width}" height="${height}" fill="${background}" rx="10"/>
20
+ <text x="${width/2}" y="${ERROR_STYLES.TEXT_Y}" text-anchor="middle" fill="${ERROR_STYLES.COLOUR}" font-family="Arial" font-size="${ERROR_STYLES.FONT_SIZE}">
21
+ Error: ${truncated}
22
+ </text>
23
+ </svg>
24
+ `.trim();
25
+ }
@@ -0,0 +1,31 @@
1
+ import { TITLE_STYLES } from "../constants/styles.js"
2
+
3
+ export function renderSvg(
4
+ width: number,
5
+ height: number,
6
+ background: string,
7
+ segments: string,
8
+ legend: string,
9
+ title: string | null,
10
+ textColour: string
11
+ ): string {
12
+ const titleElement = title ? `
13
+ <text
14
+ x="${width/2}"
15
+ y="${TITLE_STYLES.TEXT_Y}"
16
+ text-anchor="middle" fill="${textColour}"
17
+ font-family="Arial" font-size="${TITLE_STYLES.FONT_SIZE}"
18
+ >
19
+ ${title}
20
+ </text>
21
+ ` : '';
22
+
23
+ return `
24
+ <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
25
+ <rect width="${width}" height="${height}" fill="${background}" rx="10"/>
26
+ ${titleElement}
27
+ ${segments}
28
+ ${legend}
29
+ </svg>
30
+ `.trim();
31
+ }
package/src/types.ts ADDED
@@ -0,0 +1,28 @@
1
+ export type Point = {
2
+ x: number;
3
+ y: number;
4
+ };
5
+
6
+ export type Language = {
7
+ lang: string;
8
+ pct: number;
9
+ };
10
+
11
+ export type Geometry = {
12
+ CENTER_Y: number;
13
+ INNER_RADIUS: number;
14
+ OUTER_RADIUS: number;
15
+ };
16
+
17
+ export type ChartType = "donut" | "pie";
18
+
19
+ export type ChartResult = {
20
+ segments: string;
21
+ legend: string;
22
+ };
23
+
24
+ export type Theme = {
25
+ readonly colours: readonly string[];
26
+ readonly text: string;
27
+ readonly bg: string;
28
+ };