@hed-hog/core 0.0.400 → 0.0.401
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/dist/ai/ai.controller.d.ts +2 -0
- package/dist/ai/ai.controller.d.ts.map +1 -1
- package/dist/ai/ai.service.d.ts +86 -2
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +304 -23
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/document-kit/document-kit.module.d.ts +3 -0
- package/dist/document-kit/document-kit.module.d.ts.map +1 -0
- package/dist/document-kit/document-kit.module.js +24 -0
- package/dist/document-kit/document-kit.module.js.map +1 -0
- package/dist/document-kit/document-kit.registry.d.ts +55 -0
- package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
- package/dist/document-kit/document-kit.registry.js +91 -0
- package/dist/document-kit/document-kit.registry.js.map +1 -0
- package/dist/document-kit/document-kit.service.d.ts +49 -0
- package/dist/document-kit/document-kit.service.d.ts.map +1 -0
- package/dist/document-kit/document-kit.service.js +135 -0
- package/dist/document-kit/document-kit.service.js.map +1 -0
- package/dist/file/file.service.d.ts +15 -1
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +52 -24
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
- package/dist/file/mcp-file-source.schema.js +8 -5
- package/dist/file/mcp-file-source.schema.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +21 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
- package/dist/integration-profile/testers/ai.testers.js +46 -8
- package/dist/integration-profile/testers/ai.testers.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +2 -0
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
- package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
- package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
- package/dist/meeting/meeting-provider.registry.d.ts +72 -0
- package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
- package/dist/meeting/meeting-provider.registry.js.map +1 -1
- package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
- package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
- package/dist/oauth/oauth-token-broker.service.js +105 -3
- package/dist/oauth/oauth-token-broker.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
- package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-bar.js +117 -0
- package/dist/pdf/document/charts/chart-bar.js.map +1 -0
- package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
- package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-donut.js +89 -0
- package/dist/pdf/document/charts/chart-donut.js.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.js +60 -0
- package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
- package/dist/pdf/document/charts/chart-line.d.ts +29 -0
- package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-line.js +89 -0
- package/dist/pdf/document/charts/chart-line.js.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.js +110 -0
- package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
- package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
- package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-radar.js +83 -0
- package/dist/pdf/document/charts/chart-radar.js.map +1 -0
- package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
- package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-scale.js +118 -0
- package/dist/pdf/document/charts/chart-scale.js.map +1 -0
- package/dist/pdf/document/charts/index.d.ts +8 -0
- package/dist/pdf/document/charts/index.d.ts.map +1 -0
- package/dist/pdf/document/charts/index.js +24 -0
- package/dist/pdf/document/charts/index.js.map +1 -0
- package/dist/pdf/document/index.d.ts +8 -0
- package/dist/pdf/document/index.d.ts.map +1 -0
- package/dist/pdf/document/index.js +24 -0
- package/dist/pdf/document/index.js.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.js +36 -0
- package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
- package/dist/pdf/document/pdf-document.css.d.ts +20 -0
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.css.js +239 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.js +153 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
- package/dist/pdf/document/pdf-document.service.d.ts +27 -0
- package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.service.js +81 -0
- package/dist/pdf/document/pdf-document.service.js.map +1 -0
- package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
- package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.theme.js +105 -0
- package/dist/pdf/document/pdf-document.theme.js.map +1 -0
- package/dist/pdf/document/pdf-document.types.d.ts +108 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.types.js +18 -0
- package/dist/pdf/document/pdf-document.types.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -1
- package/dist/pdf/pdf.module.js +3 -2
- package/dist/pdf/pdf.module.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +69 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +164 -63
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
- package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
- package/dist/pdf/templates/commercial-proposal.template.js +276 -0
- package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
- package/dist/setting/favicon.controller.d.ts +26 -0
- package/dist/setting/favicon.controller.d.ts.map +1 -0
- package/dist/setting/favicon.controller.js +130 -0
- package/dist/setting/favicon.controller.js.map +1 -0
- package/dist/setting/setting.module.d.ts.map +1 -1
- package/dist/setting/setting.module.js +2 -1
- package/dist/setting/setting.module.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +52 -0
- package/hedhog/frontend/messages/en.json +54 -1
- package/hedhog/frontend/messages/pt.json +54 -1
- package/package.json +4 -4
- package/src/ai/ai-attachment.fragments.spec.ts +56 -3
- package/src/ai/ai.service.responses.spec.ts +429 -0
- package/src/ai/ai.service.ts +409 -27
- package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
- package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
- package/src/core.module.ts +3 -0
- package/src/document-kit/document-kit.module.ts +12 -0
- package/src/document-kit/document-kit.registry.ts +125 -0
- package/src/document-kit/document-kit.service.spec.ts +156 -0
- package/src/document-kit/document-kit.service.ts +173 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +72 -34
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +21 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
- package/src/mcp/mcp.module.ts +2 -0
- package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
- package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
- package/src/meeting/meeting-provider.registry.ts +80 -0
- package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
- package/src/oauth/oauth-token-broker.service.ts +129 -2
- package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
- package/src/pdf/document/charts/chart-bar.ts +217 -0
- package/src/pdf/document/charts/chart-donut.ts +163 -0
- package/src/pdf/document/charts/chart-gauge.ts +94 -0
- package/src/pdf/document/charts/chart-line.ts +159 -0
- package/src/pdf/document/charts/chart-primitives.ts +188 -0
- package/src/pdf/document/charts/chart-radar.ts +136 -0
- package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
- package/src/pdf/document/charts/chart-scale.ts +141 -0
- package/src/pdf/document/charts/charts.spec.ts +157 -0
- package/src/pdf/document/charts/index.ts +7 -0
- package/src/pdf/document/index.ts +7 -0
- package/src/pdf/document/pdf-document.chrome.ts +38 -0
- package/src/pdf/document/pdf-document.css.ts +238 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
- package/src/pdf/document/pdf-document.renderer.ts +215 -0
- package/src/pdf/document/pdf-document.service.ts +77 -0
- package/src/pdf/document/pdf-document.theme.ts +146 -0
- package/src/pdf/document/pdf-document.types.ts +97 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +245 -82
- package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
- package/src/pdf/templates/commercial-proposal.template.ts +400 -0
- package/src/setting/favicon.controller.spec.ts +92 -0
- package/src/setting/favicon.controller.ts +135 -0
- package/src/setting/setting.module.ts +2 -1
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
|
@@ -77,6 +77,19 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Escopos que a Microsoft devolveu no token, ou `null` quando ela não
|
|
82
|
+
* informou — só nesse caso vale cair de volta para os escopos pedidos.
|
|
83
|
+
* Uma lista vazia é resposta legítima e não pode virar "assume o pedido".
|
|
84
|
+
*/
|
|
85
|
+
private parseGrantedScopes(scope: unknown): string[] | null {
|
|
86
|
+
if (typeof scope !== 'string') return null;
|
|
87
|
+
return scope
|
|
88
|
+
.split(/[\s,]+/)
|
|
89
|
+
.map((item) => item.trim())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
/** Absolute redirect URI the web flow registers/exchanges against (the API). */
|
|
81
94
|
private buildApiRedirectUri(apiUrl: string) {
|
|
82
95
|
if (!String(apiUrl).trim()) {
|
|
@@ -155,8 +168,22 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
|
|
|
155
168
|
'https://graph.microsoft.com/v1.0/me',
|
|
156
169
|
);
|
|
157
170
|
|
|
171
|
+
// `token.scope` é o que a Microsoft de fato consentiu, que pode ser MENOS
|
|
172
|
+
// do que a setting pede — consentimento antigo do usuário, ou escopo novo
|
|
173
|
+
// ainda sem aprovação do admin. Gravar a setting como se fosse o concedido
|
|
174
|
+
// faz o refresh seguinte pedir escopo que o grant não cobre, e aí a
|
|
175
|
+
// resposta é `invalid_grant`: a UI diz "reconecte a conta", e reconectar
|
|
176
|
+
// pelo login regrava a mesma setting, então nunca sai do lugar.
|
|
177
|
+
const grantedScopes = this.parseGrantedScopes(token.scope) ?? scopes;
|
|
178
|
+
// A Microsoft não repete `offline_access` em `token.scope`, mas ter
|
|
179
|
+
// devolvido um refresh token é prova de que foi concedido.
|
|
180
|
+
if (token.refresh_token && !grantedScopes.includes('offline_access')) {
|
|
181
|
+
grantedScopes.push('offline_access');
|
|
182
|
+
}
|
|
183
|
+
const missing = scopes.filter((scope) => !grantedScopes.includes(scope));
|
|
184
|
+
|
|
158
185
|
this.logger.log(
|
|
159
|
-
`OAuth profile fetched: provider=MICROSOFT-ENTRA-ID type=${type} profileIdPresent=${profile?.id ? 'yes' : 'no'} emailPresent=${profile?.mail || profile?.userPrincipalName ? 'yes' : 'no'}`,
|
|
186
|
+
`OAuth profile fetched: provider=MICROSOFT-ENTRA-ID type=${type} profileIdPresent=${profile?.id ? 'yes' : 'no'} emailPresent=${profile?.mail || profile?.userPrincipalName ? 'yes' : 'no'} scopesGranted=${grantedScopes.length}/${scopes.length}${missing.length ? ` missing=${missing.join(' ')}` : ''}`,
|
|
160
187
|
);
|
|
161
188
|
|
|
162
189
|
const pictureUrl = 'https://graph.microsoft.com/v1.0/me/photo/$value';
|
|
@@ -165,7 +192,7 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
|
|
|
165
192
|
email: profile.mail || profile.userPrincipalName,
|
|
166
193
|
name: profile.displayName,
|
|
167
194
|
picture: pictureUrl,
|
|
168
|
-
oauth_scopes:
|
|
195
|
+
oauth_scopes: grantedScopes.join(','),
|
|
169
196
|
oauth_tokens: {
|
|
170
197
|
access_token: token.access_token,
|
|
171
198
|
refresh_token: token.refresh_token,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { SafeSvg } from '../pdf-document.types';
|
|
2
|
+
import {
|
|
3
|
+
CHART_DEFAULT_HEIGHT,
|
|
4
|
+
CHART_FONT,
|
|
5
|
+
CHART_FULL_WIDTH,
|
|
6
|
+
ChartBaseOptions,
|
|
7
|
+
ChartSeries,
|
|
8
|
+
chartColor,
|
|
9
|
+
emptyChart,
|
|
10
|
+
legendRows,
|
|
11
|
+
line,
|
|
12
|
+
svgRoot,
|
|
13
|
+
text,
|
|
14
|
+
xAxisLabels,
|
|
15
|
+
yAxis,
|
|
16
|
+
} from './chart-primitives';
|
|
17
|
+
import {
|
|
18
|
+
axisLabelPlan,
|
|
19
|
+
bandScale,
|
|
20
|
+
estimateTextWidth,
|
|
21
|
+
formatNumber,
|
|
22
|
+
linearScale,
|
|
23
|
+
niceScale,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
} from './chart-scale';
|
|
26
|
+
|
|
27
|
+
const hasData = (series: ChartSeries[]): boolean =>
|
|
28
|
+
series.some((s) => s.values.some((v) => typeof v === 'number' && Number.isFinite(v)));
|
|
29
|
+
|
|
30
|
+
const maxOf = (series: ChartSeries[], stacked: boolean): number => {
|
|
31
|
+
if (!stacked) {
|
|
32
|
+
return series.reduce(
|
|
33
|
+
(max, s) => Math.max(max, ...s.values.map((v) => (typeof v === 'number' ? v : 0))),
|
|
34
|
+
0,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const length = series.reduce((max, s) => Math.max(max, s.values.length), 0);
|
|
39
|
+
let max = 0;
|
|
40
|
+
for (let i = 0; i < length; i += 1) {
|
|
41
|
+
max = Math.max(
|
|
42
|
+
max,
|
|
43
|
+
series.reduce((sum, s) => sum + (typeof s.values[i] === 'number' ? (s.values[i] as number) : 0), 0),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return max;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Barras verticais, uma faixa por categoria. */
|
|
50
|
+
export function barChartSvg(
|
|
51
|
+
options: ChartBaseOptions & {
|
|
52
|
+
categories: string[];
|
|
53
|
+
series: ChartSeries[];
|
|
54
|
+
stacked?: boolean;
|
|
55
|
+
yMax?: number;
|
|
56
|
+
yTicks?: number;
|
|
57
|
+
showValues?: boolean;
|
|
58
|
+
legend?: boolean;
|
|
59
|
+
},
|
|
60
|
+
): SafeSvg {
|
|
61
|
+
const {
|
|
62
|
+
theme,
|
|
63
|
+
categories,
|
|
64
|
+
series,
|
|
65
|
+
stacked = false,
|
|
66
|
+
width = CHART_FULL_WIDTH,
|
|
67
|
+
height = CHART_DEFAULT_HEIGHT,
|
|
68
|
+
valueFormat = (value: number) => formatNumber(value),
|
|
69
|
+
} = options;
|
|
70
|
+
|
|
71
|
+
if (categories.length === 0 || !hasData(series)) return emptyChart(options);
|
|
72
|
+
|
|
73
|
+
const showLegend = options.legend ?? series.length > 1;
|
|
74
|
+
const legendHeight = showLegend ? 16 : 0;
|
|
75
|
+
const plot = { left: 42, right: width - 8, top: 10, bottom: height - 26 - legendHeight };
|
|
76
|
+
|
|
77
|
+
const scale = niceScale(0, options.yMax ?? maxOf(series, stacked), options.yTicks ?? 4);
|
|
78
|
+
const y = linearScale([scale.min, scale.max], [plot.bottom, plot.top]);
|
|
79
|
+
const band = bandScale(categories.length, [plot.left, plot.right]);
|
|
80
|
+
|
|
81
|
+
const parts: string[] = [
|
|
82
|
+
yAxis(scale.ticks, y, plot, theme, valueFormat),
|
|
83
|
+
line(plot.left, plot.bottom, plot.right, plot.bottom, theme.mutedFg),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const groupWidth = stacked ? band.bandwidth : band.bandwidth / series.length;
|
|
87
|
+
|
|
88
|
+
series.forEach((serie, serieIndex) => {
|
|
89
|
+
const color = chartColor(theme, serieIndex, serie.color);
|
|
90
|
+
const stackTops = new Array(categories.length).fill(plot.bottom);
|
|
91
|
+
|
|
92
|
+
categories.forEach((_, index) => {
|
|
93
|
+
const raw = serie.values[index];
|
|
94
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return;
|
|
95
|
+
|
|
96
|
+
const barX = stacked ? band.band(index) : band.band(index) + groupWidth * serieIndex;
|
|
97
|
+
const top = stacked ? stackTops[index] - (plot.bottom - y(raw)) : y(raw);
|
|
98
|
+
const barHeight = Math.max(plot.bottom - y(raw), raw > 0 ? 1 : 0);
|
|
99
|
+
|
|
100
|
+
if (stacked) stackTops[index] = top;
|
|
101
|
+
|
|
102
|
+
parts.push(
|
|
103
|
+
`<rect x="${barX.toFixed(1)}" y="${top.toFixed(1)}" width="${groupWidth.toFixed(1)}" ` +
|
|
104
|
+
`height="${barHeight.toFixed(1)}" fill="${color}" rx="1.5" />`,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// O valor só entra quando cabe dentro da largura da barra; senão vira
|
|
108
|
+
// sopa de números sobrepostos.
|
|
109
|
+
if (
|
|
110
|
+
options.showValues &&
|
|
111
|
+
!stacked &&
|
|
112
|
+
estimateTextWidth(valueFormat(raw), CHART_FONT.value) < groupWidth
|
|
113
|
+
) {
|
|
114
|
+
parts.push(
|
|
115
|
+
text(valueFormat(raw), barX + groupWidth / 2, top - 3, {
|
|
116
|
+
size: CHART_FONT.value,
|
|
117
|
+
fill: theme.ink,
|
|
118
|
+
anchor: 'middle',
|
|
119
|
+
weight: 600,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const plan = axisLabelPlan(categories, band.bandwidth, CHART_FONT.axis);
|
|
127
|
+
parts.push(xAxisLabels(categories, band.center, plot.bottom + 12, plan, band.bandwidth, theme));
|
|
128
|
+
|
|
129
|
+
if (showLegend) {
|
|
130
|
+
const legend = legendRows(
|
|
131
|
+
series.map((serie, index) => ({
|
|
132
|
+
label: serie.name,
|
|
133
|
+
color: chartColor(theme, index, serie.color),
|
|
134
|
+
})),
|
|
135
|
+
width,
|
|
136
|
+
height - 4,
|
|
137
|
+
theme,
|
|
138
|
+
);
|
|
139
|
+
parts.push(legend.markup);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return svgRoot(width, height, parts.join(''), options.ariaLabel);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Barras horizontais — a forma certa quando o rótulo é um nome (curso, aluno),
|
|
147
|
+
* que deitado vira texto girado e ilegível.
|
|
148
|
+
*/
|
|
149
|
+
export function horizontalBarChartSvg(
|
|
150
|
+
options: ChartBaseOptions & {
|
|
151
|
+
categories: string[];
|
|
152
|
+
series: ChartSeries[];
|
|
153
|
+
xMax?: number;
|
|
154
|
+
labelWidth?: number;
|
|
155
|
+
showValues?: boolean;
|
|
156
|
+
},
|
|
157
|
+
): SafeSvg {
|
|
158
|
+
const {
|
|
159
|
+
theme,
|
|
160
|
+
categories,
|
|
161
|
+
series,
|
|
162
|
+
width = CHART_FULL_WIDTH,
|
|
163
|
+
valueFormat = (value: number) => formatNumber(value),
|
|
164
|
+
showValues = true,
|
|
165
|
+
} = options;
|
|
166
|
+
|
|
167
|
+
if (categories.length === 0 || !hasData(series)) return emptyChart(options);
|
|
168
|
+
|
|
169
|
+
const rowHeight = 22;
|
|
170
|
+
const height = options.height ?? Math.max(categories.length * rowHeight + 24, 90);
|
|
171
|
+
const labelWidth = options.labelWidth ?? Math.min(Math.round(width * 0.34), 180);
|
|
172
|
+
const plot = { left: labelWidth + 8, right: width - 38, top: 8, bottom: height - 16 };
|
|
173
|
+
|
|
174
|
+
const scale = niceScale(0, options.xMax ?? maxOf(series, false), 4);
|
|
175
|
+
const x = linearScale([scale.min, scale.max], [plot.left, plot.right]);
|
|
176
|
+
const band = bandScale(categories.length, [plot.top, plot.bottom], 0.25);
|
|
177
|
+
|
|
178
|
+
const parts: string[] = scale.ticks.map((tick) =>
|
|
179
|
+
line(x(tick), plot.top, x(tick), plot.bottom, theme.line),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
categories.forEach((category, index) => {
|
|
183
|
+
parts.push(
|
|
184
|
+
text(truncateToWidth(category, CHART_FONT.axis + 1, labelWidth), labelWidth, band.center(index) + 3, {
|
|
185
|
+
size: CHART_FONT.axis + 1,
|
|
186
|
+
fill: theme.ink,
|
|
187
|
+
anchor: 'end',
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const barHeight = band.bandwidth / series.length;
|
|
192
|
+
|
|
193
|
+
series.forEach((serie, serieIndex) => {
|
|
194
|
+
const raw = serie.values[index];
|
|
195
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return;
|
|
196
|
+
|
|
197
|
+
const barY = band.band(index) + barHeight * serieIndex;
|
|
198
|
+
const barWidth = Math.max(x(raw) - plot.left, raw > 0 ? 1 : 0);
|
|
199
|
+
|
|
200
|
+
parts.push(
|
|
201
|
+
`<rect x="${plot.left}" y="${barY.toFixed(1)}" width="${barWidth.toFixed(1)}" ` +
|
|
202
|
+
`height="${barHeight.toFixed(1)}" fill="${chartColor(theme, serieIndex, serie.color)}" rx="1.5" />`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (showValues && series.length === 1) {
|
|
206
|
+
parts.push(
|
|
207
|
+
text(valueFormat(raw), plot.left + barWidth + 4, barY + barHeight / 2 + 3, {
|
|
208
|
+
size: CHART_FONT.value,
|
|
209
|
+
fill: theme.mutedFg,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return svgRoot(width, height, parts.join(''), options.ariaLabel);
|
|
217
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { SafeSvg } from '../pdf-document.types';
|
|
2
|
+
import {
|
|
3
|
+
CHART_DEFAULT_HEIGHT,
|
|
4
|
+
CHART_FONT,
|
|
5
|
+
CHART_FULL_WIDTH,
|
|
6
|
+
ChartBaseOptions,
|
|
7
|
+
chartColor,
|
|
8
|
+
emptyChart,
|
|
9
|
+
svgRoot,
|
|
10
|
+
text,
|
|
11
|
+
} from './chart-primitives';
|
|
12
|
+
import { estimateTextWidth, formatNumber, truncateToWidth } from './chart-scale';
|
|
13
|
+
|
|
14
|
+
export interface DonutSlice {
|
|
15
|
+
label: string;
|
|
16
|
+
value: number;
|
|
17
|
+
color?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const polar = (cx: number, cy: number, radius: number, angle: number) => ({
|
|
21
|
+
x: cx + radius * Math.cos(angle),
|
|
22
|
+
y: cy + radius * Math.sin(angle),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Setor em anel, desenhado com dois arcos — o `path` é o único jeito no SVG puro. */
|
|
26
|
+
function ringSegment(
|
|
27
|
+
cx: number,
|
|
28
|
+
cy: number,
|
|
29
|
+
outer: number,
|
|
30
|
+
inner: number,
|
|
31
|
+
start: number,
|
|
32
|
+
end: number,
|
|
33
|
+
): string {
|
|
34
|
+
const largeArc = end - start > Math.PI ? 1 : 0;
|
|
35
|
+
const p1 = polar(cx, cy, outer, start);
|
|
36
|
+
const p2 = polar(cx, cy, outer, end);
|
|
37
|
+
const p3 = polar(cx, cy, inner, end);
|
|
38
|
+
const p4 = polar(cx, cy, inner, start);
|
|
39
|
+
|
|
40
|
+
if (inner <= 0) {
|
|
41
|
+
return (
|
|
42
|
+
`M${cx.toFixed(1)},${cy.toFixed(1)} L${p1.x.toFixed(1)},${p1.y.toFixed(1)} ` +
|
|
43
|
+
`A${outer},${outer} 0 ${largeArc} 1 ${p2.x.toFixed(1)},${p2.y.toFixed(1)} Z`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
`M${p1.x.toFixed(1)},${p1.y.toFixed(1)} A${outer},${outer} 0 ${largeArc} 1 ${p2.x.toFixed(1)},${p2.y.toFixed(1)} ` +
|
|
49
|
+
`L${p3.x.toFixed(1)},${p3.y.toFixed(1)} A${inner},${inner} 0 ${largeArc} 0 ${p4.x.toFixed(1)},${p4.y.toFixed(1)} Z`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Rosca (ou pizza, com `innerRadiusRatio: 0`) com legenda à direita. */
|
|
54
|
+
export function donutChartSvg(
|
|
55
|
+
options: ChartBaseOptions & {
|
|
56
|
+
slices: DonutSlice[];
|
|
57
|
+
innerRadiusRatio?: number;
|
|
58
|
+
centerLabel?: string;
|
|
59
|
+
centerValue?: string;
|
|
60
|
+
showPercent?: boolean;
|
|
61
|
+
},
|
|
62
|
+
): SafeSvg {
|
|
63
|
+
const {
|
|
64
|
+
theme,
|
|
65
|
+
slices,
|
|
66
|
+
width = CHART_FULL_WIDTH,
|
|
67
|
+
height = CHART_DEFAULT_HEIGHT,
|
|
68
|
+
innerRadiusRatio = 0.62,
|
|
69
|
+
showPercent = true,
|
|
70
|
+
valueFormat = (value: number) => formatNumber(value, 0),
|
|
71
|
+
} = options;
|
|
72
|
+
|
|
73
|
+
const usable = slices.filter((slice) => Number.isFinite(slice.value) && slice.value > 0);
|
|
74
|
+
const total = usable.reduce((sum, slice) => sum + slice.value, 0);
|
|
75
|
+
|
|
76
|
+
if (total <= 0) return emptyChart(options);
|
|
77
|
+
|
|
78
|
+
// A coluna da direita é dimensionada pelo texto que realmente vai nela: o
|
|
79
|
+
// valor ("318 · 76%") fixa a largura mínima, e o que sobra é do rótulo.
|
|
80
|
+
const valueLabels = usable.map((slice) =>
|
|
81
|
+
showPercent
|
|
82
|
+
? `${valueFormat(slice.value)} · ${Math.round((slice.value / total) * 100)}%`
|
|
83
|
+
: valueFormat(slice.value),
|
|
84
|
+
);
|
|
85
|
+
const valueWidth = Math.max(
|
|
86
|
+
...valueLabels.map((label) => estimateTextWidth(label, CHART_FONT.legend)),
|
|
87
|
+
);
|
|
88
|
+
// A largura do rótulo é decidida primeiro e a legenda é montada a partir dela.
|
|
89
|
+
// Derivar na ordem inversa (legenda menos valor menos folga) devolve o mesmo
|
|
90
|
+
// número com um epsilon de ponto flutuante a menos, e um rótulo que cabia
|
|
91
|
+
// certinho sai cortado — "Em risco" virava "Em ris…".
|
|
92
|
+
const maxLabelWidth = Math.min(
|
|
93
|
+
Math.max(...usable.map((s) => estimateTextWidth(s.label, CHART_FONT.legend))),
|
|
94
|
+
width * 0.45 - valueWidth - 26,
|
|
95
|
+
);
|
|
96
|
+
const legendWidth = maxLabelWidth + valueWidth + 26;
|
|
97
|
+
const chartWidth = width - legendWidth;
|
|
98
|
+
const radius = Math.min(chartWidth, height) / 2 - 8;
|
|
99
|
+
const cx = chartWidth / 2;
|
|
100
|
+
const cy = height / 2;
|
|
101
|
+
const inner = radius * innerRadiusRatio;
|
|
102
|
+
|
|
103
|
+
const parts: string[] = [];
|
|
104
|
+
// Começa às 12h: é onde o olho procura o início de uma rosca.
|
|
105
|
+
let angle = -Math.PI / 2;
|
|
106
|
+
|
|
107
|
+
usable.forEach((slice, index) => {
|
|
108
|
+
const sweep = (slice.value / total) * Math.PI * 2;
|
|
109
|
+
const color = chartColor(theme, index, slice.color);
|
|
110
|
+
|
|
111
|
+
parts.push(
|
|
112
|
+
`<path d="${ringSegment(cx, cy, radius, inner, angle, angle + sweep)}" fill="${color}" />`,
|
|
113
|
+
);
|
|
114
|
+
angle += sweep;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (inner > 0 && (options.centerValue || options.centerLabel)) {
|
|
118
|
+
if (options.centerValue) {
|
|
119
|
+
parts.push(
|
|
120
|
+
text(options.centerValue, cx, cy + (options.centerLabel ? 0 : 4), {
|
|
121
|
+
size: 16,
|
|
122
|
+
fill: theme.ink,
|
|
123
|
+
anchor: 'middle',
|
|
124
|
+
weight: 700,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (options.centerLabel) {
|
|
129
|
+
parts.push(
|
|
130
|
+
text(options.centerLabel, cx, cy + 12, {
|
|
131
|
+
size: CHART_FONT.axis,
|
|
132
|
+
fill: theme.mutedFg,
|
|
133
|
+
anchor: 'middle',
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const legendX = chartWidth + 4;
|
|
140
|
+
const lineHeight = 15;
|
|
141
|
+
const legendTop = cy - (usable.length * lineHeight) / 2 + 8;
|
|
142
|
+
|
|
143
|
+
usable.forEach((slice, index) => {
|
|
144
|
+
const y = legendTop + index * lineHeight;
|
|
145
|
+
const label = truncateToWidth(
|
|
146
|
+
slice.label,
|
|
147
|
+
CHART_FONT.legend,
|
|
148
|
+
maxLabelWidth,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
parts.push(
|
|
152
|
+
`<rect x="${legendX}" y="${y - 7}" width="8" height="8" rx="2" fill="${chartColor(theme, index, slice.color)}" />`,
|
|
153
|
+
text(label, legendX + 12, y, { size: CHART_FONT.legend, fill: theme.ink }),
|
|
154
|
+
text(valueLabels[index] ?? '', width, y, {
|
|
155
|
+
size: CHART_FONT.legend,
|
|
156
|
+
fill: theme.mutedFg,
|
|
157
|
+
anchor: 'end',
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return svgRoot(width, height, parts.join(''), options.ariaLabel);
|
|
163
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { SafeSvg } from '../pdf-document.types';
|
|
2
|
+
import {
|
|
3
|
+
CHART_DEFAULT_HEIGHT,
|
|
4
|
+
CHART_FONT,
|
|
5
|
+
CHART_FULL_WIDTH,
|
|
6
|
+
ChartBaseOptions,
|
|
7
|
+
chartColor,
|
|
8
|
+
emptyChart,
|
|
9
|
+
svgRoot,
|
|
10
|
+
text,
|
|
11
|
+
} from './chart-primitives';
|
|
12
|
+
import { truncateToWidth } from './chart-scale';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Arcos concêntricos de percentual — a figura de "meu progresso por curso" da
|
|
16
|
+
* visão do aluno. Cada item é um anel; o arco completo é 100%.
|
|
17
|
+
*/
|
|
18
|
+
export function gaugeChartSvg(
|
|
19
|
+
options: ChartBaseOptions & {
|
|
20
|
+
items: Array<{ label: string; pct: number; color?: string }>;
|
|
21
|
+
centerLabel?: string;
|
|
22
|
+
},
|
|
23
|
+
): SafeSvg {
|
|
24
|
+
const {
|
|
25
|
+
theme,
|
|
26
|
+
items,
|
|
27
|
+
width = CHART_FULL_WIDTH,
|
|
28
|
+
height = CHART_DEFAULT_HEIGHT,
|
|
29
|
+
} = options;
|
|
30
|
+
|
|
31
|
+
const usable = items.filter((item) => Number.isFinite(item.pct)).slice(0, 6);
|
|
32
|
+
if (usable.length === 0) return emptyChart(options);
|
|
33
|
+
|
|
34
|
+
const legendWidth = Math.min(width * 0.45, 240);
|
|
35
|
+
const chartWidth = width - legendWidth;
|
|
36
|
+
const cx = chartWidth / 2;
|
|
37
|
+
const cy = height / 2;
|
|
38
|
+
|
|
39
|
+
const outer = Math.min(chartWidth, height) / 2 - 8;
|
|
40
|
+
const thickness = Math.max((outer - 18) / usable.length, 6);
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
|
|
43
|
+
usable.forEach((item, index) => {
|
|
44
|
+
const radius = outer - index * thickness - thickness / 2;
|
|
45
|
+
if (radius <= 2) return;
|
|
46
|
+
|
|
47
|
+
const color = chartColor(theme, index, item.color);
|
|
48
|
+
const circumference = 2 * Math.PI * radius;
|
|
49
|
+
const pct = Math.min(Math.max(item.pct, 0), 100);
|
|
50
|
+
|
|
51
|
+
parts.push(
|
|
52
|
+
// Trilho + arco, com o traço girado para começar às 12h.
|
|
53
|
+
`<circle cx="${cx}" cy="${cy}" r="${radius.toFixed(1)}" fill="none" stroke="${theme.mutedBg}" ` +
|
|
54
|
+
`stroke-width="${(thickness - 2).toFixed(1)}" />`,
|
|
55
|
+
`<circle cx="${cx}" cy="${cy}" r="${radius.toFixed(1)}" fill="none" stroke="${color}" ` +
|
|
56
|
+
`stroke-width="${(thickness - 2).toFixed(1)}" stroke-linecap="round" ` +
|
|
57
|
+
`stroke-dasharray="${((circumference * pct) / 100).toFixed(1)} ${circumference.toFixed(1)}" ` +
|
|
58
|
+
`transform="rotate(-90 ${cx} ${cy})" />`,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (options.centerLabel) {
|
|
63
|
+
parts.push(
|
|
64
|
+
text(options.centerLabel, cx, cy + 3, {
|
|
65
|
+
size: CHART_FONT.axis,
|
|
66
|
+
fill: theme.mutedFg,
|
|
67
|
+
anchor: 'middle',
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const legendX = chartWidth + 4;
|
|
73
|
+
const lineHeight = 16;
|
|
74
|
+
const legendTop = cy - (usable.length * lineHeight) / 2 + 8;
|
|
75
|
+
|
|
76
|
+
usable.forEach((item, index) => {
|
|
77
|
+
const y = legendTop + index * lineHeight;
|
|
78
|
+
|
|
79
|
+
parts.push(
|
|
80
|
+
`<rect x="${legendX}" y="${y - 7}" width="8" height="8" rx="2" fill="${chartColor(theme, index, item.color)}" />`,
|
|
81
|
+
text(truncateToWidth(item.label, CHART_FONT.legend, legendWidth - 60), legendX + 12, y, {
|
|
82
|
+
size: CHART_FONT.legend,
|
|
83
|
+
fill: theme.ink,
|
|
84
|
+
}),
|
|
85
|
+
text(`${Math.round(item.pct)}%`, width, y, {
|
|
86
|
+
size: CHART_FONT.legend,
|
|
87
|
+
fill: theme.mutedFg,
|
|
88
|
+
anchor: 'end',
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return svgRoot(width, height, parts.join(''), options.ariaLabel);
|
|
94
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { SafeSvg } from '../pdf-document.types';
|
|
2
|
+
import {
|
|
3
|
+
CHART_DEFAULT_HEIGHT,
|
|
4
|
+
CHART_FONT,
|
|
5
|
+
CHART_FULL_WIDTH,
|
|
6
|
+
ChartBaseOptions,
|
|
7
|
+
ChartSeries,
|
|
8
|
+
chartColor,
|
|
9
|
+
emptyChart,
|
|
10
|
+
legendRows,
|
|
11
|
+
line,
|
|
12
|
+
svgRoot,
|
|
13
|
+
text,
|
|
14
|
+
xAxisLabels,
|
|
15
|
+
yAxis,
|
|
16
|
+
} from './chart-primitives';
|
|
17
|
+
import {
|
|
18
|
+
axisLabelPlan,
|
|
19
|
+
bandScale,
|
|
20
|
+
formatNumber,
|
|
21
|
+
linearScale,
|
|
22
|
+
niceScale,
|
|
23
|
+
withAlpha,
|
|
24
|
+
} from './chart-scale';
|
|
25
|
+
|
|
26
|
+
export interface LineChartOptions extends ChartBaseOptions {
|
|
27
|
+
categories: string[];
|
|
28
|
+
series: ChartSeries[];
|
|
29
|
+
/** Preenche a área sob a linha. */
|
|
30
|
+
area?: boolean;
|
|
31
|
+
yMin?: number;
|
|
32
|
+
yMax?: number;
|
|
33
|
+
yTicks?: number;
|
|
34
|
+
/** Linha tracejada de meta (ex.: frequência de 80%). */
|
|
35
|
+
referenceLine?: { value: number; label?: string };
|
|
36
|
+
showDots?: boolean;
|
|
37
|
+
legend?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Linha (ou área) ao longo de categorias.
|
|
42
|
+
*
|
|
43
|
+
* Buraco na série vira interrupção da linha, não zero: um mês sem dado e um mês
|
|
44
|
+
* com zero matrículas são coisas diferentes, e emendar os dois inventa uma
|
|
45
|
+
* queda que não houve.
|
|
46
|
+
*/
|
|
47
|
+
export function lineChartSvg(options: LineChartOptions): SafeSvg {
|
|
48
|
+
const {
|
|
49
|
+
theme,
|
|
50
|
+
categories,
|
|
51
|
+
series,
|
|
52
|
+
width = CHART_FULL_WIDTH,
|
|
53
|
+
height = CHART_DEFAULT_HEIGHT,
|
|
54
|
+
valueFormat = (value: number) => formatNumber(value),
|
|
55
|
+
} = options;
|
|
56
|
+
|
|
57
|
+
const hasData = series.some((s) => s.values.some((v) => typeof v === 'number' && Number.isFinite(v)));
|
|
58
|
+
if (categories.length === 0 || !hasData) return emptyChart(options);
|
|
59
|
+
|
|
60
|
+
const showLegend = options.legend ?? series.length > 1;
|
|
61
|
+
const legendHeight = showLegend ? 16 : 0;
|
|
62
|
+
const plot = { left: 42, right: width - 8, top: 10, bottom: height - 26 - legendHeight };
|
|
63
|
+
|
|
64
|
+
const values = series
|
|
65
|
+
.flatMap((s) => s.values)
|
|
66
|
+
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
|
|
67
|
+
const reference = options.referenceLine?.value;
|
|
68
|
+
|
|
69
|
+
const scale = niceScale(
|
|
70
|
+
options.yMin ?? Math.min(0, ...values),
|
|
71
|
+
options.yMax ?? Math.max(...values, reference ?? Number.NEGATIVE_INFINITY),
|
|
72
|
+
options.yTicks ?? 4,
|
|
73
|
+
);
|
|
74
|
+
const y = linearScale([scale.min, scale.max], [plot.bottom, plot.top]);
|
|
75
|
+
const band = bandScale(categories.length, [plot.left, plot.right], 0);
|
|
76
|
+
|
|
77
|
+
const parts: string[] = [
|
|
78
|
+
yAxis(scale.ticks, y, plot, theme, valueFormat),
|
|
79
|
+
line(plot.left, plot.bottom, plot.right, plot.bottom, theme.mutedFg),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
if (typeof reference === 'number') {
|
|
83
|
+
parts.push(
|
|
84
|
+
line(plot.left, y(reference), plot.right, y(reference), theme.mutedFg, { dash: '4 3' }),
|
|
85
|
+
text(options.referenceLine?.label ?? valueFormat(reference), plot.right, y(reference) - 4, {
|
|
86
|
+
size: CHART_FONT.axis,
|
|
87
|
+
fill: theme.mutedFg,
|
|
88
|
+
anchor: 'end',
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const showDots = options.showDots ?? categories.length <= 24;
|
|
94
|
+
|
|
95
|
+
series.forEach((serie, serieIndex) => {
|
|
96
|
+
const color = chartColor(theme, serieIndex, serie.color);
|
|
97
|
+
const segments: Array<Array<{ x: number; y: number }>> = [];
|
|
98
|
+
let current: Array<{ x: number; y: number }> = [];
|
|
99
|
+
|
|
100
|
+
categories.forEach((_, index) => {
|
|
101
|
+
const raw = serie.values[index];
|
|
102
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
|
103
|
+
if (current.length) segments.push(current);
|
|
104
|
+
current = [];
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
current.push({ x: band.center(index), y: y(raw) });
|
|
108
|
+
});
|
|
109
|
+
if (current.length) segments.push(current);
|
|
110
|
+
|
|
111
|
+
segments.forEach((points) => {
|
|
112
|
+
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
|
113
|
+
|
|
114
|
+
if (options.area && points.length > 1) {
|
|
115
|
+
const first = points[0]!;
|
|
116
|
+
const last = points[points.length - 1]!;
|
|
117
|
+
parts.push(
|
|
118
|
+
`<path d="${path} L${last.x.toFixed(1)},${plot.bottom} L${first.x.toFixed(1)},${plot.bottom} Z" ` +
|
|
119
|
+
`fill="${withAlpha(color, 0.18)}" stroke="none" />`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
parts.push(
|
|
124
|
+
`<path d="${path}" fill="none" stroke="${color}" stroke-width="1.8" ` +
|
|
125
|
+
'stroke-linejoin="round" stroke-linecap="round" />',
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (showDots) {
|
|
129
|
+
points.forEach((point) => {
|
|
130
|
+
parts.push(
|
|
131
|
+
`<circle cx="${point.x.toFixed(1)}" cy="${point.y.toFixed(1)}" r="2.2" fill="${color}" />`,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const plan = axisLabelPlan(categories, band.bandwidth, CHART_FONT.axis);
|
|
139
|
+
parts.push(xAxisLabels(categories, band.center, plot.bottom + 12, plan, band.bandwidth, theme));
|
|
140
|
+
|
|
141
|
+
if (showLegend) {
|
|
142
|
+
const legend = legendRows(
|
|
143
|
+
series.map((serie, index) => ({
|
|
144
|
+
label: serie.name,
|
|
145
|
+
color: chartColor(theme, index, serie.color),
|
|
146
|
+
})),
|
|
147
|
+
width,
|
|
148
|
+
height - 4,
|
|
149
|
+
theme,
|
|
150
|
+
);
|
|
151
|
+
parts.push(legend.markup);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return svgRoot(width, height, parts.join(''), options.ariaLabel);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Mesma figura com a área preenchida — o nome existe para o chamador ler melhor. */
|
|
158
|
+
export const areaChartSvg = (options: LineChartOptions): SafeSvg =>
|
|
159
|
+
lineChartSvg({ ...options, area: true });
|