@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { escapeHtml } from '../pdf-document.renderer';
|
|
2
|
+
import { PdfDocumentTheme } from '../pdf-document.theme';
|
|
3
|
+
import { asSafeSvg, SafeSvg } from '../pdf-document.types';
|
|
4
|
+
import { estimateTextWidth, formatNumber, truncateToWidth } from './chart-scale';
|
|
5
|
+
|
|
6
|
+
/** Opções comuns a todos os gráficos do kit. */
|
|
7
|
+
export interface ChartBaseOptions {
|
|
8
|
+
theme: PdfDocumentTheme;
|
|
9
|
+
/** Padrão 682px — a largura útil de um A4 retrato com as margens do documento. */
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
/** Descrição para leitor de tela; vira `<title>` do SVG. */
|
|
13
|
+
ariaLabel?: string;
|
|
14
|
+
valueFormat?: (value: number) => string;
|
|
15
|
+
emptyText?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChartSeries {
|
|
19
|
+
name: string;
|
|
20
|
+
values: Array<number | null>;
|
|
21
|
+
color?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Largura útil do A4 retrato com as margens de 56px do documento padrão. */
|
|
25
|
+
export const CHART_FULL_WIDTH = 682;
|
|
26
|
+
/** Largura de cada gráfico numa grade de duas colunas. */
|
|
27
|
+
export const CHART_HALF_WIDTH = 330;
|
|
28
|
+
export const CHART_DEFAULT_HEIGHT = 220;
|
|
29
|
+
|
|
30
|
+
/** Tamanhos de fonte. Abaixo de 8px o texto ainda imprime, mas não se lê na tela. */
|
|
31
|
+
export const CHART_FONT = { axis: 8, value: 9, legend: 9 };
|
|
32
|
+
|
|
33
|
+
export const chartColor = (theme: PdfDocumentTheme, index: number, override?: string): string =>
|
|
34
|
+
override ?? theme.chartPalette[index % theme.chartPalette.length] ?? theme.primary;
|
|
35
|
+
|
|
36
|
+
export const text = (
|
|
37
|
+
content: string,
|
|
38
|
+
x: number,
|
|
39
|
+
y: number,
|
|
40
|
+
options: {
|
|
41
|
+
size?: number;
|
|
42
|
+
fill?: string;
|
|
43
|
+
anchor?: 'start' | 'middle' | 'end';
|
|
44
|
+
weight?: number;
|
|
45
|
+
rotate?: number;
|
|
46
|
+
} = {},
|
|
47
|
+
): string => {
|
|
48
|
+
const { size = CHART_FONT.axis, fill = '#71717a', anchor = 'start', weight = 400, rotate } = options;
|
|
49
|
+
const transform = rotate ? ` transform="rotate(${rotate} ${x} ${y})"` : '';
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
`<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}" ` +
|
|
53
|
+
`font-weight="${weight}"${transform}>${escapeHtml(content)}</text>`
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const line = (
|
|
58
|
+
x1: number,
|
|
59
|
+
y1: number,
|
|
60
|
+
x2: number,
|
|
61
|
+
y2: number,
|
|
62
|
+
stroke: string,
|
|
63
|
+
options: { width?: number; dash?: string } = {},
|
|
64
|
+
): string =>
|
|
65
|
+
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" ` +
|
|
66
|
+
`stroke-width="${options.width ?? 1}"${options.dash ? ` stroke-dasharray="${options.dash}"` : ''} ` +
|
|
67
|
+
'shape-rendering="crispEdges" />';
|
|
68
|
+
|
|
69
|
+
/** Envelope do SVG. `viewBox` + largura relativa deixam o gráfico acompanhar a página. */
|
|
70
|
+
export function svgRoot(
|
|
71
|
+
width: number,
|
|
72
|
+
height: number,
|
|
73
|
+
body: string,
|
|
74
|
+
ariaLabel?: string,
|
|
75
|
+
): SafeSvg {
|
|
76
|
+
return asSafeSvg(
|
|
77
|
+
`<svg viewBox="0 0 ${width} ${height}" width="100%" height="${height}" ` +
|
|
78
|
+
`xmlns="http://www.w3.org/2000/svg" role="img">` +
|
|
79
|
+
(ariaLabel ? `<title>${escapeHtml(ariaLabel)}</title>` : '') +
|
|
80
|
+
body +
|
|
81
|
+
'</svg>',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Placeholder de gráfico sem dados — melhor que uma moldura vazia sem explicação. */
|
|
86
|
+
export function emptyChart(options: ChartBaseOptions): SafeSvg {
|
|
87
|
+
const width = options.width ?? CHART_FULL_WIDTH;
|
|
88
|
+
const height = options.height ?? CHART_DEFAULT_HEIGHT;
|
|
89
|
+
const label = options.emptyText ?? 'Sem dados no período';
|
|
90
|
+
|
|
91
|
+
return svgRoot(
|
|
92
|
+
width,
|
|
93
|
+
height,
|
|
94
|
+
`<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" fill="none" ` +
|
|
95
|
+
`stroke="${options.theme.line}" stroke-dasharray="4 3" rx="8" />` +
|
|
96
|
+
text(label, width / 2, height / 2, {
|
|
97
|
+
size: 10,
|
|
98
|
+
fill: options.theme.mutedFg,
|
|
99
|
+
anchor: 'middle',
|
|
100
|
+
}),
|
|
101
|
+
options.ariaLabel,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Legenda em uma linha, com quebra automática quando não cabe. */
|
|
106
|
+
export function legendRows(
|
|
107
|
+
entries: Array<{ label: string; color: string }>,
|
|
108
|
+
width: number,
|
|
109
|
+
y: number,
|
|
110
|
+
theme: PdfDocumentTheme,
|
|
111
|
+
): { markup: string; height: number } {
|
|
112
|
+
const gap = 14;
|
|
113
|
+
const swatch = 8;
|
|
114
|
+
let cursorX = 0;
|
|
115
|
+
let cursorY = y;
|
|
116
|
+
const parts: string[] = [];
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const labelWidth = estimateTextWidth(entry.label, CHART_FONT.legend);
|
|
120
|
+
const entryWidth = swatch + 4 + labelWidth + gap;
|
|
121
|
+
|
|
122
|
+
if (cursorX > 0 && cursorX + entryWidth > width) {
|
|
123
|
+
cursorX = 0;
|
|
124
|
+
cursorY += 13;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
parts.push(
|
|
128
|
+
`<rect x="${cursorX}" y="${cursorY - swatch + 1}" width="${swatch}" height="${swatch}" rx="2" fill="${entry.color}" />`,
|
|
129
|
+
text(entry.label, cursorX + swatch + 4, cursorY, {
|
|
130
|
+
size: CHART_FONT.legend,
|
|
131
|
+
fill: theme.mutedFg,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
cursorX += entryWidth;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { markup: parts.join(''), height: cursorY - y + 13 };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Grade horizontal + rótulos do eixo Y. */
|
|
142
|
+
export function yAxis(
|
|
143
|
+
ticks: number[],
|
|
144
|
+
scale: (value: number) => number,
|
|
145
|
+
plot: { left: number; right: number },
|
|
146
|
+
theme: PdfDocumentTheme,
|
|
147
|
+
format: (value: number) => string = (value) => formatNumber(value),
|
|
148
|
+
): string {
|
|
149
|
+
return ticks
|
|
150
|
+
.map((tick) => {
|
|
151
|
+
const y = scale(tick);
|
|
152
|
+
return (
|
|
153
|
+
line(plot.left, y, plot.right, y, theme.line) +
|
|
154
|
+
text(format(tick), plot.left - 5, y + 3, {
|
|
155
|
+
fill: theme.mutedFg,
|
|
156
|
+
anchor: 'end',
|
|
157
|
+
size: CHART_FONT.axis,
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
})
|
|
161
|
+
.join('');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Rótulos do eixo X, já cortados e girados conforme o espaço. */
|
|
165
|
+
export function xAxisLabels(
|
|
166
|
+
labels: string[],
|
|
167
|
+
center: (index: number) => number,
|
|
168
|
+
baseline: number,
|
|
169
|
+
plan: { rotate: 0 | -35; skipEvery: number },
|
|
170
|
+
bandwidth: number,
|
|
171
|
+
theme: PdfDocumentTheme,
|
|
172
|
+
): string {
|
|
173
|
+
return labels
|
|
174
|
+
.map((label, index) => {
|
|
175
|
+
if (index % plan.skipEvery !== 0) return '';
|
|
176
|
+
|
|
177
|
+
const x = center(index);
|
|
178
|
+
const maxWidth = plan.rotate === 0 ? bandwidth : bandwidth * 3;
|
|
179
|
+
|
|
180
|
+
return text(truncateToWidth(label, CHART_FONT.axis, maxWidth), x, baseline, {
|
|
181
|
+
fill: theme.mutedFg,
|
|
182
|
+
anchor: plan.rotate === 0 ? 'middle' : 'end',
|
|
183
|
+
size: CHART_FONT.axis,
|
|
184
|
+
rotate: plan.rotate,
|
|
185
|
+
});
|
|
186
|
+
})
|
|
187
|
+
.join('');
|
|
188
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
svgRoot,
|
|
12
|
+
text,
|
|
13
|
+
} from './chart-primitives';
|
|
14
|
+
import { formatNumber, niceScale, truncateToWidth, withAlpha } from './chart-scale';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Radar de eixos comparáveis — a forma da tela para "nota média por tópico",
|
|
18
|
+
* onde todos os eixos compartilham a mesma escala (0 a 5).
|
|
19
|
+
*/
|
|
20
|
+
export function radarChartSvg(
|
|
21
|
+
options: ChartBaseOptions & {
|
|
22
|
+
axes: string[];
|
|
23
|
+
series: ChartSeries[];
|
|
24
|
+
max?: number;
|
|
25
|
+
rings?: number;
|
|
26
|
+
},
|
|
27
|
+
): SafeSvg {
|
|
28
|
+
const {
|
|
29
|
+
theme,
|
|
30
|
+
axes,
|
|
31
|
+
series,
|
|
32
|
+
width = CHART_FULL_WIDTH,
|
|
33
|
+
height = CHART_DEFAULT_HEIGHT,
|
|
34
|
+
rings = 4,
|
|
35
|
+
valueFormat = (value: number) => formatNumber(value),
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
const values = series
|
|
39
|
+
.flatMap((s) => s.values)
|
|
40
|
+
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
|
|
41
|
+
|
|
42
|
+
// Abaixo de 3 eixos o radar vira uma linha ou um triângulo degenerado, que
|
|
43
|
+
// engana mais do que informa.
|
|
44
|
+
if (axes.length < 3 || values.length === 0) return emptyChart(options);
|
|
45
|
+
|
|
46
|
+
const max = options.max ?? niceScale(0, Math.max(...values), rings).max;
|
|
47
|
+
const showLegend = series.length > 1;
|
|
48
|
+
const legendHeight = showLegend ? 16 : 0;
|
|
49
|
+
|
|
50
|
+
const cx = width / 2;
|
|
51
|
+
const cy = (height - legendHeight) / 2;
|
|
52
|
+
const radius = Math.min(cx, cy) - 26;
|
|
53
|
+
|
|
54
|
+
const angleOf = (index: number) => (index / axes.length) * Math.PI * 2 - Math.PI / 2;
|
|
55
|
+
const point = (index: number, value: number) => {
|
|
56
|
+
const ratio = max === 0 ? 0 : Math.min(Math.max(value / max, 0), 1);
|
|
57
|
+
const angle = angleOf(index);
|
|
58
|
+
return { x: cx + radius * ratio * Math.cos(angle), y: cy + radius * ratio * Math.sin(angle) };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
|
|
63
|
+
for (let ring = 1; ring <= rings; ring += 1) {
|
|
64
|
+
const r = (radius * ring) / rings;
|
|
65
|
+
const polygon = axes
|
|
66
|
+
.map((_, index) => {
|
|
67
|
+
const angle = angleOf(index);
|
|
68
|
+
return `${(cx + r * Math.cos(angle)).toFixed(1)},${(cy + r * Math.sin(angle)).toFixed(1)}`;
|
|
69
|
+
})
|
|
70
|
+
.join(' ');
|
|
71
|
+
|
|
72
|
+
parts.push(`<polygon points="${polygon}" fill="none" stroke="${theme.line}" stroke-width="1" />`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
axes.forEach((axis, index) => {
|
|
76
|
+
const angle = angleOf(index);
|
|
77
|
+
const edgeX = cx + radius * Math.cos(angle);
|
|
78
|
+
const edgeY = cy + radius * Math.sin(angle);
|
|
79
|
+
|
|
80
|
+
parts.push(
|
|
81
|
+
`<line x1="${cx}" y1="${cy}" x2="${edgeX.toFixed(1)}" y2="${edgeY.toFixed(1)}" stroke="${theme.line}" />`,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const labelX = cx + (radius + 12) * Math.cos(angle);
|
|
85
|
+
const labelY = cy + (radius + 12) * Math.sin(angle) + 3;
|
|
86
|
+
const anchor = Math.abs(Math.cos(angle)) < 0.3 ? 'middle' : Math.cos(angle) > 0 ? 'start' : 'end';
|
|
87
|
+
|
|
88
|
+
parts.push(
|
|
89
|
+
text(truncateToWidth(axis, CHART_FONT.axis, 90), labelX, labelY, {
|
|
90
|
+
size: CHART_FONT.axis,
|
|
91
|
+
fill: theme.mutedFg,
|
|
92
|
+
anchor,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
series.forEach((serie, serieIndex) => {
|
|
98
|
+
const color = chartColor(theme, serieIndex, serie.color);
|
|
99
|
+
const points = axes.map((_, index) => {
|
|
100
|
+
const raw = serie.values[index];
|
|
101
|
+
return point(index, typeof raw === 'number' && Number.isFinite(raw) ? raw : 0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
parts.push(
|
|
105
|
+
`<polygon points="${points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')}" ` +
|
|
106
|
+
`fill="${withAlpha(color, 0.18)}" stroke="${color}" stroke-width="1.8" stroke-linejoin="round" />`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
points.forEach((p) => {
|
|
110
|
+
parts.push(`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="2" fill="${color}" />`);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
parts.push(
|
|
115
|
+
text(`Escala 0 a ${valueFormat(max)}`, width, height - legendHeight - 2, {
|
|
116
|
+
size: CHART_FONT.axis,
|
|
117
|
+
fill: theme.mutedFg,
|
|
118
|
+
anchor: 'end',
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (showLegend) {
|
|
123
|
+
const legend = legendRows(
|
|
124
|
+
series.map((serie, index) => ({
|
|
125
|
+
label: serie.name,
|
|
126
|
+
color: chartColor(theme, index, serie.color),
|
|
127
|
+
})),
|
|
128
|
+
width,
|
|
129
|
+
height - 4,
|
|
130
|
+
theme,
|
|
131
|
+
);
|
|
132
|
+
parts.push(legend.markup);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return svgRoot(width, height, parts.join(''), options.ariaLabel);
|
|
136
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
axisLabelPlan,
|
|
5
|
+
bandScale,
|
|
6
|
+
estimateTextWidth,
|
|
7
|
+
linearScale,
|
|
8
|
+
niceScale,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
withAlpha,
|
|
11
|
+
} from './chart-scale';
|
|
12
|
+
|
|
13
|
+
describe('niceScale', () => {
|
|
14
|
+
it('arredonda o topo para um valor redondo', () => {
|
|
15
|
+
const scale = niceScale(0, 97, 4);
|
|
16
|
+
|
|
17
|
+
expect(scale.max).toBe(100);
|
|
18
|
+
expect(scale.step).toBe(25);
|
|
19
|
+
expect(scale.ticks).toEqual([0, 25, 50, 75, 100]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Uma série constante (ou toda zero) não tem amplitude: sem este caso a
|
|
23
|
+
// divisão pelo intervalo daria NaN e o gráfico sairia vazio.
|
|
24
|
+
it('sobrevive a série sem amplitude', () => {
|
|
25
|
+
expect(niceScale(0, 0).ticks.length).toBeGreaterThan(1);
|
|
26
|
+
expect(niceScale(7, 7).max).toBe(7);
|
|
27
|
+
expect(niceScale(7, 7).ticks.every((t) => Number.isFinite(t))).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('inclui o último tick apesar do erro de ponto flutuante', () => {
|
|
31
|
+
const scale = niceScale(0, 0.3, 3);
|
|
32
|
+
|
|
33
|
+
expect(scale.ticks[scale.ticks.length - 1]).toBeGreaterThanOrEqual(0.3);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('linearScale', () => {
|
|
38
|
+
it('mapeia o domínio no intervalo de pixels, invertendo o eixo Y', () => {
|
|
39
|
+
const y = linearScale([0, 100], [200, 0]);
|
|
40
|
+
|
|
41
|
+
expect(y(0)).toBe(200);
|
|
42
|
+
expect(y(100)).toBe(0);
|
|
43
|
+
expect(y(50)).toBe(100);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('não divide por zero quando o domínio é degenerado', () => {
|
|
47
|
+
expect(Number.isFinite(linearScale([5, 5], [0, 10])(5))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('bandScale', () => {
|
|
52
|
+
it('distribui as faixas com folga entre elas', () => {
|
|
53
|
+
const band = bandScale(4, [0, 400], 0.2);
|
|
54
|
+
|
|
55
|
+
expect(band.bandwidth).toBe(80);
|
|
56
|
+
expect(band.center(0)).toBe(50);
|
|
57
|
+
expect(band.center(3)).toBe(350);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('medidas de texto', () => {
|
|
62
|
+
it('corta com reticências quando não cabe', () => {
|
|
63
|
+
const long = 'Curso de Introdução à Inteligência Artificial';
|
|
64
|
+
|
|
65
|
+
expect(truncateToWidth(long, 8, 40)).toMatch(/…$/);
|
|
66
|
+
expect(estimateTextWidth(truncateToWidth(long, 8, 40), 8)).toBeLessThanOrEqual(44);
|
|
67
|
+
expect(truncateToWidth('Curto', 8, 200)).toBe('Curto');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('deita o rótulo quando cabe e gira quando não cabe', () => {
|
|
71
|
+
expect(axisLabelPlan(['jan', 'fev'], 40, 8).rotate).toBe(0);
|
|
72
|
+
expect(axisLabelPlan(['Janeiro de 2026', 'Fevereiro de 2026'], 20, 8).rotate).toBe(-35);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('pula rótulos quando nem girado cabe', () => {
|
|
76
|
+
const plan = axisLabelPlan(
|
|
77
|
+
Array.from({ length: 40 }, (_, i) => `Semana ${i + 1} de 2026`),
|
|
78
|
+
6,
|
|
79
|
+
8,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(plan.skipEvery).toBeGreaterThan(1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('withAlpha', () => {
|
|
87
|
+
it.each([
|
|
88
|
+
['#ff760c', 'rgba(255,118,12,0.18)'],
|
|
89
|
+
['#FFF', 'rgba(255,255,255,0.18)'],
|
|
90
|
+
])('converte %s', (color, expected) => {
|
|
91
|
+
expect(withAlpha(color, 0.18)).toBe(expected);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escalas, medidas e formatação dos gráficos do documento PDF.
|
|
3
|
+
*
|
|
4
|
+
* Tudo aqui é função pura e sem dependência: o gráfico é desenhado como SVG no
|
|
5
|
+
* servidor, onde não há canvas para medir texto nem biblioteca de plotagem.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Domínio arredondado para valores "redondos" (1, 2, 5 × 10^k) e os ticks dele. */
|
|
9
|
+
export function niceScale(
|
|
10
|
+
min: number,
|
|
11
|
+
max: number,
|
|
12
|
+
tickCount = 4,
|
|
13
|
+
): { min: number; max: number; step: number; ticks: number[] } {
|
|
14
|
+
const safeMin = Number.isFinite(min) ? min : 0;
|
|
15
|
+
const safeMax = Number.isFinite(max) ? max : 0;
|
|
16
|
+
|
|
17
|
+
// Série toda igual (ou toda zero) não tem amplitude para dividir: um passo de
|
|
18
|
+
// 1 mantém o eixo legível em vez de gerar divisão por zero.
|
|
19
|
+
if (safeMax === safeMin) {
|
|
20
|
+
const value = safeMax === 0 ? 1 : Math.abs(safeMax);
|
|
21
|
+
const step = value / tickCount || 1;
|
|
22
|
+
return {
|
|
23
|
+
min: Math.min(0, safeMin),
|
|
24
|
+
max: safeMax === 0 ? 1 : safeMax,
|
|
25
|
+
step,
|
|
26
|
+
ticks: Array.from({ length: tickCount + 1 }, (_, i) => Math.min(0, safeMin) + step * i),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rawStep = (safeMax - safeMin) / Math.max(tickCount, 1);
|
|
31
|
+
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
|
|
32
|
+
const normalized = rawStep / magnitude;
|
|
33
|
+
// O 2,5 está na escada de propósito: sem ele, um eixo de 0 a 97 pedindo 4
|
|
34
|
+
// divisões cai no passo 50 e imprime só 0/50/100 — informação de menos para
|
|
35
|
+
// um relatório que se lê no papel, sem passar o mouse em nada.
|
|
36
|
+
const niceNormalized =
|
|
37
|
+
normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10;
|
|
38
|
+
const step = niceNormalized * magnitude;
|
|
39
|
+
|
|
40
|
+
const niceMin = Math.floor(safeMin / step) * step;
|
|
41
|
+
const niceMax = Math.ceil(safeMax / step) * step;
|
|
42
|
+
|
|
43
|
+
const ticks: number[] = [];
|
|
44
|
+
// Tolerância pelo passo: somar 0.1+0.2 em ponto flutuante erraria o último tick.
|
|
45
|
+
for (let value = niceMin; value <= niceMax + step / 1000; value += step) {
|
|
46
|
+
ticks.push(Number(value.toFixed(10)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { min: niceMin, max: niceMax, step, ticks };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Converte valor do domínio em pixel. */
|
|
53
|
+
export function linearScale(
|
|
54
|
+
domain: [number, number],
|
|
55
|
+
range: [number, number],
|
|
56
|
+
): (value: number) => number {
|
|
57
|
+
const [d0, d1] = domain;
|
|
58
|
+
const [r0, r1] = range;
|
|
59
|
+
const span = d1 - d0 || 1;
|
|
60
|
+
|
|
61
|
+
return (value: number) => r0 + ((value - d0) / span) * (r1 - r0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Divide um intervalo em faixas de mesma largura, uma por categoria. */
|
|
65
|
+
export function bandScale(
|
|
66
|
+
count: number,
|
|
67
|
+
range: [number, number],
|
|
68
|
+
paddingRatio = 0.2,
|
|
69
|
+
): { band: (index: number) => number; center: (index: number) => number; bandwidth: number } {
|
|
70
|
+
const [r0, r1] = range;
|
|
71
|
+
const total = r1 - r0;
|
|
72
|
+
const slots = Math.max(count, 1);
|
|
73
|
+
const slot = total / slots;
|
|
74
|
+
const bandwidth = slot * (1 - paddingRatio);
|
|
75
|
+
const offset = (slot - bandwidth) / 2;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
band: (index: number) => r0 + slot * index + offset,
|
|
79
|
+
center: (index: number) => r0 + slot * index + slot / 2,
|
|
80
|
+
bandwidth,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Largura aproximada de um texto. Sem canvas no servidor, 0,55 × tamanho da
|
|
86
|
+
* fonte por caractere erra pouco para Arial/FreeSans e é o bastante para
|
|
87
|
+
* decidir corte e rotação de rótulo.
|
|
88
|
+
*/
|
|
89
|
+
export function estimateTextWidth(text: string, fontSize: number): number {
|
|
90
|
+
return text.length * fontSize * 0.55;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Corta o texto no limite de pixels, com reticências. */
|
|
94
|
+
export function truncateToWidth(text: string, fontSize: number, maxWidth: number): string {
|
|
95
|
+
if (estimateTextWidth(text, fontSize) <= maxWidth) return text;
|
|
96
|
+
|
|
97
|
+
const maxChars = Math.max(Math.floor(maxWidth / (fontSize * 0.55)) - 1, 1);
|
|
98
|
+
return `${text.slice(0, maxChars).trimEnd()}…`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Decide se os rótulos do eixo X cabem deitados e quantos pular. */
|
|
102
|
+
export function axisLabelPlan(
|
|
103
|
+
labels: string[],
|
|
104
|
+
bandwidth: number,
|
|
105
|
+
fontSize: number,
|
|
106
|
+
): { rotate: 0 | -35; skipEvery: number } {
|
|
107
|
+
const widest = labels.reduce((max, label) => Math.max(max, estimateTextWidth(label, fontSize)), 0);
|
|
108
|
+
|
|
109
|
+
if (widest <= bandwidth) return { rotate: 0, skipEvery: 1 };
|
|
110
|
+
// Girar resolve até certo ponto; passado ele, é melhor mostrar menos rótulos
|
|
111
|
+
// do que empilhar texto ilegível.
|
|
112
|
+
if (widest <= bandwidth * 3) return { rotate: -35, skipEvery: 1 };
|
|
113
|
+
|
|
114
|
+
return { rotate: -35, skipEvery: Math.ceil(widest / (bandwidth * 3)) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `#ff760c` + alpha → `rgba(255,118,12,0.18)`, para o preenchimento de área. */
|
|
118
|
+
export function withAlpha(hexColor: string, alpha: number): string {
|
|
119
|
+
const hex = hexColor.replace('#', '');
|
|
120
|
+
const full =
|
|
121
|
+
hex.length === 3
|
|
122
|
+
? hex
|
|
123
|
+
.split('')
|
|
124
|
+
.map((char) => char + char)
|
|
125
|
+
.join('')
|
|
126
|
+
: hex;
|
|
127
|
+
|
|
128
|
+
const value = Number.parseInt(full.slice(0, 6), 16);
|
|
129
|
+
if (!Number.isFinite(value)) return hexColor;
|
|
130
|
+
|
|
131
|
+
const r = (value >> 16) & 255;
|
|
132
|
+
const g = (value >> 8) & 255;
|
|
133
|
+
const b = value & 255;
|
|
134
|
+
|
|
135
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Número no formato pt-BR, sem casas decimais desnecessárias. */
|
|
139
|
+
export function formatNumber(value: number, maximumFractionDigits = 1): string {
|
|
140
|
+
return new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value);
|
|
141
|
+
}
|