@gh-top-languages/lib 1.0.3 → 1.0.4
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.
- package/LICENSE +21 -21
- package/README.md +36 -33
- package/dist/charts/donut.d.ts +1 -1
- package/dist/charts/donut.d.ts.map +1 -1
- package/dist/charts/donut.js +5 -6
- package/dist/charts/donut.js.map +1 -1
- package/dist/charts/geometry.js +11 -11
- package/dist/charts/layout.d.ts +13 -2
- package/dist/charts/layout.d.ts.map +1 -1
- package/dist/charts/layout.js +27 -8
- package/dist/charts/layout.js.map +1 -1
- package/dist/charts/legend.d.ts +1 -1
- package/dist/charts/legend.d.ts.map +1 -1
- package/dist/charts/legend.js +20 -20
- package/dist/charts/legend.js.map +1 -1
- package/dist/charts/pie.d.ts +1 -1
- package/dist/charts/pie.d.ts.map +1 -1
- package/dist/charts/pie.js +5 -6
- package/dist/charts/pie.js.map +1 -1
- package/dist/constants/styles.d.ts +4 -2
- package/dist/constants/styles.d.ts.map +1 -1
- package/dist/constants/styles.js +18 -2
- package/dist/constants/styles.js.map +1 -1
- package/dist/render/chart.d.ts +1 -1
- package/dist/render/chart.d.ts.map +1 -1
- package/dist/render/chart.js +2 -2
- package/dist/render/chart.js.map +1 -1
- package/dist/render/error.js +7 -7
- package/dist/render/svg.d.ts +2 -1
- package/dist/render/svg.d.ts.map +1 -1
- package/dist/render/svg.js +21 -18
- package/dist/render/svg.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +48 -48
- package/src/charts/donut.ts +38 -22
- package/src/charts/geometry.ts +88 -88
- package/src/charts/layout.ts +50 -19
- package/src/charts/legend.ts +54 -53
- package/src/charts/pie.ts +41 -22
- package/src/constants/config.ts +11 -11
- package/src/constants/geometry.ts +9 -9
- package/src/constants/styles.ts +40 -23
- package/src/constants/themes.ts +68 -68
- package/src/constants/types.ts +4 -4
- package/src/render/chart.ts +22 -24
- package/src/render/error.ts +25 -25
- package/src/render/svg.ts +30 -31
- package/src/types.ts +30 -28
- package/src/utils/params.ts +47 -47
- package/src/utils/sanitize.ts +14 -14
package/package.json
CHANGED
|
@@ -1,48 +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.
|
|
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
|
-
}
|
|
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.4",
|
|
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 -p tsconfig.test.json",
|
|
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
|
+
}
|
package/src/charts/donut.ts
CHANGED
|
@@ -1,22 +1,38 @@
|
|
|
1
|
-
import type { Language, Theme, ChartResult
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
export function generateDonutChart(
|
|
8
|
-
normalizedLanguages: Language[],
|
|
9
|
-
selectedTheme: Theme,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
import type { Language, Theme, ChartResult } from "../types.js";
|
|
2
|
+
import { DONUT_GEOMETRY } from "../constants/geometry.js";
|
|
3
|
+
import { createDonutSegments } from "./geometry.js";
|
|
4
|
+
import { createLegend } from "./legend.js";
|
|
5
|
+
import { resolveLayout, computeLayout } from "./layout.js";
|
|
6
|
+
|
|
7
|
+
export function generateDonutChart(
|
|
8
|
+
normalizedLanguages: Language[],
|
|
9
|
+
selectedTheme: Theme,
|
|
10
|
+
stroke: boolean
|
|
11
|
+
): ChartResult {
|
|
12
|
+
const { isShifted, useStroke } = resolveLayout(normalizedLanguages.length, stroke);
|
|
13
|
+
const {
|
|
14
|
+
chartX,
|
|
15
|
+
legendStartX,
|
|
16
|
+
columnWidth,
|
|
17
|
+
contentWidth,
|
|
18
|
+
contentHeight
|
|
19
|
+
} = computeLayout(normalizedLanguages, isShifted, DONUT_GEOMETRY);
|
|
20
|
+
|
|
21
|
+
const segments = createDonutSegments(
|
|
22
|
+
normalizedLanguages,
|
|
23
|
+
chartX,
|
|
24
|
+
DONUT_GEOMETRY,
|
|
25
|
+
[...selectedTheme.colours],
|
|
26
|
+
useStroke
|
|
27
|
+
);
|
|
28
|
+
const legend = createLegend(
|
|
29
|
+
normalizedLanguages,
|
|
30
|
+
isShifted,
|
|
31
|
+
selectedTheme,
|
|
32
|
+
legendStartX,
|
|
33
|
+
useStroke,
|
|
34
|
+
columnWidth
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return { segments, legend, contentWidth, contentHeight };
|
|
38
|
+
}
|
package/src/charts/geometry.ts
CHANGED
|
@@ -1,88 +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
|
-
}
|
|
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
|
+
}
|
package/src/charts/layout.ts
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
1
|
-
import { LEGEND_SHIFT_THRESHOLD, LEGEND_STYLES, CHART_MARGIN_RIGHT } from "../constants/styles.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export function
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
import { LEGEND_SHIFT_THRESHOLD, LEGEND_STYLES, CHART_MARGIN_RIGHT, ARIAL_CHAR_WIDTHS, DEFAULT_CHAR_WIDTH } from "../constants/styles.js";
|
|
2
|
+
import type { Geometry, Language } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export function resolveLayout(count: number, stroke: boolean) {
|
|
5
|
+
return {
|
|
6
|
+
isShifted: count > LEGEND_SHIFT_THRESHOLD,
|
|
7
|
+
useStroke: count > 1 ? stroke : false
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const measureText = (text: string, fontSize: number): number => [...text]
|
|
12
|
+
.reduce((sum, ch) => sum + (ARIAL_CHAR_WIDTHS[ch] ?? DEFAULT_CHAR_WIDTH), 0) * fontSize / 1000;
|
|
13
|
+
|
|
14
|
+
const estimateEntryWidth = (label: string): number =>
|
|
15
|
+
LEGEND_STYLES.SQUARE_SIZE + LEGEND_STYLES.TEXT_GAP + measureText(label, LEGEND_STYLES.FONT_SIZE);
|
|
16
|
+
|
|
17
|
+
export function measureLegend(languages: Language[], isShifted: boolean) {
|
|
18
|
+
const entryWidth = (lang: Language) => estimateEntryWidth(`${lang.lang} ${lang.pct.toFixed(1)}%`);
|
|
19
|
+
|
|
20
|
+
if (!isShifted) {
|
|
21
|
+
const width = languages.reduce((max, l) => Math.max(max, entryWidth(l)), 0);
|
|
22
|
+
return { columnWidth: width, legendWidth: width };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const half = Math.ceil(languages.length / 2);
|
|
26
|
+
const col0Width = languages.slice(0, half).reduce((max, l) => Math.max(max, entryWidth(l)), 0);
|
|
27
|
+
const col1Width = languages.slice(half).reduce((max, l) => Math.max(max, entryWidth(l)), 0);
|
|
28
|
+
|
|
29
|
+
const columnWidth = col0Width + LEGEND_STYLES.COLUMN_GAP;
|
|
30
|
+
const legendWidth = columnWidth + col1Width;
|
|
31
|
+
|
|
32
|
+
return { columnWidth, legendWidth };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CONTENT_PAD = 20;
|
|
36
|
+
|
|
37
|
+
export function computeLayout(languages: Language[], isShifted: boolean, geometry: Geometry) {
|
|
38
|
+
const { columnWidth, legendWidth } = measureLegend(languages, isShifted);
|
|
39
|
+
|
|
40
|
+
const chartX = CONTENT_PAD + geometry.OUTER_RADIUS;
|
|
41
|
+
const legendStartX = chartX + geometry.OUTER_RADIUS + CHART_MARGIN_RIGHT;
|
|
42
|
+
const contentWidth = legendStartX + legendWidth + CONTENT_PAD;
|
|
43
|
+
|
|
44
|
+
const rows = isShifted ? Math.ceil(languages.length / 2) : languages.length;
|
|
45
|
+
const legendBottom = LEGEND_STYLES.START_Y + (rows - 1) * LEGEND_STYLES.ROW_HEIGHT + LEGEND_STYLES.SQUARE_SIZE;
|
|
46
|
+
const chartBottom = geometry.CENTER_Y + geometry.OUTER_RADIUS;
|
|
47
|
+
const contentHeight = Math.max(chartBottom, legendBottom) + CONTENT_PAD;
|
|
48
|
+
|
|
49
|
+
return { chartX, legendStartX, columnWidth, contentWidth, contentHeight };
|
|
50
|
+
}
|
package/src/charts/legend.ts
CHANGED
|
@@ -1,53 +1,54 @@
|
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
font-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
columnWidth: number
|
|
11
|
+
): string {
|
|
12
|
+
const numLangs = languages.length;
|
|
13
|
+
|
|
14
|
+
return languages.map((lang, i) => {
|
|
15
|
+
let x: number, y: number;
|
|
16
|
+
|
|
17
|
+
if (!isShifted) {
|
|
18
|
+
x = legendStartX;
|
|
19
|
+
y = LEGEND_STYLES.START_Y + i * LEGEND_STYLES.ROW_HEIGHT;
|
|
20
|
+
} else {
|
|
21
|
+
const half = Math.ceil(numLangs / 2);
|
|
22
|
+
const col = Math.floor(i / half);
|
|
23
|
+
const row = i % half;
|
|
24
|
+
|
|
25
|
+
x = legendStartX + col * columnWidth;
|
|
26
|
+
y = LEGEND_STYLES.START_Y + row * LEGEND_STYLES.ROW_HEIGHT;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fill = selectedTheme.colours[i];
|
|
30
|
+
const strokeAttr = stroke
|
|
31
|
+
? ` stroke="#000" stroke-width="0.5" stroke-linejoin="round"`
|
|
32
|
+
: ``;
|
|
33
|
+
|
|
34
|
+
return `
|
|
35
|
+
<rect
|
|
36
|
+
x="${x}"
|
|
37
|
+
y="${y - LEGEND_STYLES.SQUARE_SIZE + 3}"
|
|
38
|
+
width="${LEGEND_STYLES.SQUARE_SIZE}"
|
|
39
|
+
height="${LEGEND_STYLES.SQUARE_SIZE}"
|
|
40
|
+
fill="${fill}"
|
|
41
|
+
rx="${LEGEND_STYLES.SQUARE_RADIUS}"${strokeAttr}
|
|
42
|
+
/>
|
|
43
|
+
<text
|
|
44
|
+
x="${x + LEGEND_STYLES.SQUARE_SIZE + LEGEND_STYLES.TEXT_GAP}"
|
|
45
|
+
y="${y}"
|
|
46
|
+
fill="${selectedTheme.text}"
|
|
47
|
+
font-size="${LEGEND_STYLES.FONT_SIZE}"
|
|
48
|
+
font-family="Arial"
|
|
49
|
+
>
|
|
50
|
+
${lang.lang} ${lang.pct.toFixed(1)}%
|
|
51
|
+
</text>
|
|
52
|
+
`;
|
|
53
|
+
}).join('');
|
|
54
|
+
}
|
package/src/charts/pie.ts
CHANGED
|
@@ -1,22 +1,41 @@
|
|
|
1
|
-
import type { Language, Theme, ChartResult
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
1
|
+
import type { Language, Theme, ChartResult } from "../types.js";
|
|
2
|
+
import { PIE_GEOMETRY } from "../constants/geometry.js";
|
|
3
|
+
import { createDonutSegments } from "./geometry.js";
|
|
4
|
+
import { createLegend } from "./legend.js";
|
|
5
|
+
import {
|
|
6
|
+
resolveLayout,
|
|
7
|
+
computeLayout
|
|
8
|
+
} from "./layout.js";
|
|
9
|
+
|
|
10
|
+
export function generatePieChart(
|
|
11
|
+
normalizedLanguages: Language[],
|
|
12
|
+
selectedTheme: Theme,
|
|
13
|
+
stroke: boolean
|
|
14
|
+
): ChartResult {
|
|
15
|
+
const { isShifted, useStroke } = resolveLayout(normalizedLanguages.length, stroke);
|
|
16
|
+
const {
|
|
17
|
+
chartX,
|
|
18
|
+
legendStartX,
|
|
19
|
+
columnWidth,
|
|
20
|
+
contentWidth,
|
|
21
|
+
contentHeight
|
|
22
|
+
} = computeLayout(normalizedLanguages, isShifted, PIE_GEOMETRY);
|
|
23
|
+
|
|
24
|
+
const segments = createDonutSegments(
|
|
25
|
+
normalizedLanguages,
|
|
26
|
+
chartX,
|
|
27
|
+
PIE_GEOMETRY,
|
|
28
|
+
[...selectedTheme.colours],
|
|
29
|
+
useStroke
|
|
30
|
+
);
|
|
31
|
+
const legend = createLegend(
|
|
32
|
+
normalizedLanguages,
|
|
33
|
+
isShifted,
|
|
34
|
+
selectedTheme,
|
|
35
|
+
legendStartX,
|
|
36
|
+
useStroke,
|
|
37
|
+
columnWidth
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return { segments, legend, contentWidth, contentHeight };
|
|
41
|
+
}
|
package/src/constants/config.ts
CHANGED
|
@@ -1,11 +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;
|
|
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;
|
|
@@ -1,9 +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;
|
|
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;
|