sagadeck 1.2.0
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 -0
- package/README.md +305 -0
- package/SKILL.md +148 -0
- package/bin/sagadeck.js +511 -0
- package/docs/ANDAMENTO.md +1255 -0
- package/docs/EXPERIENCIAS.md +39 -0
- package/docs/PLANO-EXPERIENCIAS.md +39 -0
- package/docs/PLANO-TESTE-AULAS-TECNICAS.md +44 -0
- package/docs/PLANO-TESTE-DESINFORMACAO.md +38 -0
- package/docs/REFERENCIA.md +1131 -0
- package/package.json +59 -0
- package/src/adaptive-layouts.js +18 -0
- package/src/ai/art-direction.js +8 -0
- package/src/ai/commands.js +130 -0
- package/src/ai/context.js +245 -0
- package/src/ai/deck-ai.js +1135 -0
- package/src/ai/directions.js +74 -0
- package/src/ai/document-materials.js +49 -0
- package/src/ai/document-visuals.js +215 -0
- package/src/ai/evaluation.js +31 -0
- package/src/ai/ground.js +55 -0
- package/src/ai/llm.js +292 -0
- package/src/ai/quality.js +51 -0
- package/src/ai/relay.js +45 -0
- package/src/ai/staged-generation.js +24 -0
- package/src/ai/transform.js +858 -0
- package/src/ai/usage.js +17 -0
- package/src/ai/variety.js +80 -0
- package/src/ai/video-frames.js +24 -0
- package/src/ai/video-generation.js +126 -0
- package/src/ai/web-operation.js +31 -0
- package/src/algo-catalog.js +153 -0
- package/src/algo-trace.js +139 -0
- package/src/api-client.js +514 -0
- package/src/api-collections.js +14 -0
- package/src/api-demo.js +222 -0
- package/src/aspect.js +40 -0
- package/src/build.js +323 -0
- package/src/carousel.js +56 -0
- package/src/chrome.js +65 -0
- package/src/code-highlight.js +68 -0
- package/src/code-lab-browser.js +22 -0
- package/src/code-lab.js +3 -0
- package/src/code-language.js +52 -0
- package/src/csv.js +74 -0
- package/src/decision-lab.js +20 -0
- package/src/deck-file.js +119 -0
- package/src/diagram/napkin-examples.js +13 -0
- package/src/diagram/napkin.js +539 -0
- package/src/docx.js +128 -0
- package/src/dynamics/git-sim.js +200 -0
- package/src/dynamics/layouts.js +112 -0
- package/src/elements.js +554 -0
- package/src/experiences.js +124 -0
- package/src/exploration.js +31 -0
- package/src/export/browser.js +53 -0
- package/src/export/estudo.js +67 -0
- package/src/export/pptx.js +475 -0
- package/src/export/roteiro.js +40 -0
- package/src/export/shots.js +182 -0
- package/src/figures/charts.js +383 -0
- package/src/figures/diagrams.js +117 -0
- package/src/figures/icons.js +166 -0
- package/src/figures/material-pictos.js +20 -0
- package/src/figures/pictos.js +255 -0
- package/src/figures/points.js +106 -0
- package/src/figures/qr.js +29 -0
- package/src/figures/ufmap.js +48 -0
- package/src/fiscal/autofix.js +377 -0
- package/src/fiscal/normalize.js +115 -0
- package/src/graph-lab.js +24 -0
- package/src/hologram.js +30 -0
- package/src/identity.js +86 -0
- package/src/import/crop.js +59 -0
- package/src/import/index.js +132 -0
- package/src/import/legacy-word.js +42 -0
- package/src/import/merge.js +45 -0
- package/src/import/office.js +123 -0
- package/src/import/omml.js +69 -0
- package/src/import/pdf-render.js +56 -0
- package/src/import/pptx.js +701 -0
- package/src/import/xml.js +42 -0
- package/src/infographic.js +401 -0
- package/src/layouts.js +723 -0
- package/src/lessons.js +171 -0
- package/src/library.js +399 -0
- package/src/markup.js +116 -0
- package/src/master.js +214 -0
- package/src/mcp/server.js +371 -0
- package/src/meeting.js +42 -0
- package/src/motion.js +56 -0
- package/src/package.js +116 -0
- package/src/playground.js +19 -0
- package/src/portal-scene.js +20 -0
- package/src/preferences.js +61 -0
- package/src/protect.js +51 -0
- package/src/purpose.js +24 -0
- package/src/pytrace.js +1092 -0
- package/src/research/duckduckgo.js +73 -0
- package/src/research/research.js +202 -0
- package/src/research/svg-image.js +17 -0
- package/src/research/visuals.js +52 -0
- package/src/runtime/api-collection-ui.js +5 -0
- package/src/runtime/api-collections.js +28 -0
- package/src/runtime/api-core.js +530 -0
- package/src/runtime/api-ui.js +966 -0
- package/src/runtime/base.css +1774 -0
- package/src/runtime/calc.js +139 -0
- package/src/runtime/code-lab.js +371 -0
- package/src/runtime/decision-lab.js +49 -0
- package/src/runtime/diagram.js +341 -0
- package/src/runtime/fit.js +268 -0
- package/src/runtime/fonts/caveat.css +2 -0
- package/src/runtime/fonts/comic-neue.css +3 -0
- package/src/runtime/fonts/fredoka.css +2 -0
- package/src/runtime/fonts/index.json +10 -0
- package/src/runtime/fonts/inter.css +2 -0
- package/src/runtime/fonts/patrick-hand.css +2 -0
- package/src/runtime/fonts/plus-jakarta-sans.css +2 -0
- package/src/runtime/fonts/press-start-2p.css +2 -0
- package/src/runtime/fonts/vt323.css +2 -0
- package/src/runtime/formula.js +148 -0
- package/src/runtime/graph-lab.js +72 -0
- package/src/runtime/meeting.css +7 -0
- package/src/runtime/meeting.js +10 -0
- package/src/runtime/motion.js +15 -0
- package/src/runtime/playground.js +11 -0
- package/src/runtime/points.js +19 -0
- package/src/runtime/portal-scene.css +6 -0
- package/src/runtime/portal-scene.js +22 -0
- package/src/runtime/runtime.js +827 -0
- package/src/runtime/science.js +89 -0
- package/src/runtime/skins/aluminio.css +31 -0
- package/src/runtime/skins/arcade.css +25 -0
- package/src/runtime/skins/aurora.css +17 -0
- package/src/runtime/skins/bauhaus.css +19 -0
- package/src/runtime/skins/editorial.css +19 -0
- package/src/runtime/skins/jornal.css +17 -0
- package/src/runtime/skins/manual-noite.css +14 -0
- package/src/runtime/skins/manual.css +14 -0
- package/src/runtime/skins/noite.css +21 -0
- package/src/runtime/skins/oceano.css +17 -0
- package/src/runtime/skins/pop.css +23 -0
- package/src/runtime/skins/prata.css +18 -0
- package/src/runtime/skins/prisma.css +28 -0
- package/src/runtime/skins/rabisco.css +18 -0
- package/src/runtime/skins/relevo.css +33 -0
- package/src/runtime/skins/sinal.css +16 -0
- package/src/runtime/skins/terminal.css +23 -0
- package/src/runtime/vendor/KATEX-LICENSE.txt +21 -0
- package/src/runtime/vendor/MERMAID-LICENSE.txt +21 -0
- package/src/runtime/vendor/PLOTLY-LICENSE.txt +21 -0
- package/src/runtime/vendor/katex.css +1 -0
- package/src/runtime/vendor/mermaid.min.js +3636 -0
- package/src/runtime/vendor/plotly.min.js +3620 -0
- package/src/runtime/video-player.js +35 -0
- package/src/science.js +162 -0
- package/src/studio/ai-response.js +36 -0
- package/src/studio/api-routes.js +152 -0
- package/src/studio/assets/contexto/leia-me.md +12 -0
- package/src/studio/assets/contexto/vendas.csv +7 -0
- package/src/studio/assets/imagens/colecao-arquitetura.png +0 -0
- package/src/studio/assets/imagens/colecao-cor.png +0 -0
- package/src/studio/assets/imagens/colecao-equipe.png +0 -0
- package/src/studio/assets/imagens/lavanda-cover.jpg +0 -0
- package/src/studio/code-routes.js +32 -0
- package/src/studio/command-approval.js +48 -0
- package/src/studio/demo-decks.js +98 -0
- package/src/studio/direction-routes.js +29 -0
- package/src/studio/errors.js +65 -0
- package/src/studio/exploration-demo.js +17 -0
- package/src/studio/exporting.js +105 -0
- package/src/studio/generation-report.js +10 -0
- package/src/studio/instance.js +48 -0
- package/src/studio/layout-samples.js +152 -0
- package/src/studio/project.js +371 -0
- package/src/studio/public/api-collections.js +28 -0
- package/src/studio/public/app.js +5265 -0
- package/src/studio/public/art-preview.js +10 -0
- package/src/studio/public/calc-fields.js +11 -0
- package/src/studio/public/chat-progress.js +47 -0
- package/src/studio/public/developer-fields.js +22 -0
- package/src/studio/public/explorer.js +443 -0
- package/src/studio/public/history.js +58 -0
- package/src/studio/public/index.html +867 -0
- package/src/studio/public/inspector.js +270 -0
- package/src/studio/public/lab-fields.js +5 -0
- package/src/studio/public/library.css +275 -0
- package/src/studio/public/library.html +55 -0
- package/src/studio/public/library.js +471 -0
- package/src/studio/public/merge-decks.js +155 -0
- package/src/studio/public/review-ui.js +100 -0
- package/src/studio/public/screenshot-editor.js +140 -0
- package/src/studio/public/share-ui.js +72 -0
- package/src/studio/public/slide-clipboard.js +23 -0
- package/src/studio/public/slide-form.js +1263 -0
- package/src/studio/public/studio-awareness.js +154 -0
- package/src/studio/public/studio-next.css +303 -0
- package/src/studio/public/style.css +1262 -0
- package/src/studio/public/ui-icons.js +2 -0
- package/src/studio/public/viewers.js +372 -0
- package/src/studio/public/visual-editor.js +326 -0
- package/src/studio/public/workspace.js +18 -0
- package/src/studio/public-files.js +1 -0
- package/src/studio/request-helpers.js +41 -0
- package/src/studio/server.js +1583 -0
- package/src/studio/share-routes.js +146 -0
- package/src/studio/slide-copy-routes.js +26 -0
- package/src/studio/slide-copy.js +30 -0
- package/src/studio/snapshot.js +160 -0
- package/src/studio/style-routes.js +103 -0
- package/src/studio/template-collections.js +97 -0
- package/src/studio/versions.js +95 -0
- package/src/studio/video-download.js +29 -0
- package/src/studio/visual-keys.js +52 -0
- package/src/studio/workspaces.js +39 -0
- package/src/table.js +93 -0
- package/src/templates/scaffold.js +372 -0
- package/src/themes.js +604 -0
- package/src/trace-view.js +450 -0
- package/src/uid.js +19 -0
- package/src/video-player.js +40 -0
- package/src/visual-edits.js +99 -0
- package/src/ws.js +134 -0
- package/templates/cenario/Texto no cen/303/241rio.yaml" +171 -0
- package/templates/cenario/imagens/cidade.svg +30 -0
- package/templates/cenario/imagens/pessoa.svg +15 -0
- package/templates/cenario-e-ciencia.yaml +47 -0
- package/templates/ensaio-api.yaml +160 -0
- package/templates/exemplo-alegre.yaml +75 -0
- package/templates/exemplo-keynote.yaml +90 -0
- package/templates/exemplo-napkin.yaml +139 -0
- package/templates/exemplo.yaml +228 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// PNG de cada slide, folha de contato, PDF e verificação automática de qualidade.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { openDeck, waitForResources } from "./browser.js";
|
|
5
|
+
|
|
6
|
+
// Estado final de cada slide (todos os cliques revelados)
|
|
7
|
+
export async function shots(htmlFile, outDir, { steps = false, scale = 1, only, jpeg = false } = {}) {
|
|
8
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
9
|
+
for (const f of fs.readdirSync(outDir)) if (/^slide-\d+.*\.(png|jpg)$|^folha-\d+\.png$/.test(f)) fs.unlinkSync(path.join(outDir, f));
|
|
10
|
+
const { browser, page, errors } = await openDeck(htmlFile, { scale });
|
|
11
|
+
try {
|
|
12
|
+
const n = await page.evaluate(() => window.sagadeck.n);
|
|
13
|
+
const files = [];
|
|
14
|
+
for (let i = 0; i < n; i++) {
|
|
15
|
+
if (only && !only.includes(i + 1)) continue;
|
|
16
|
+
const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
|
|
17
|
+
const list = steps ? Array.from({ length: S + 1 }, (_, k) => k) : [S];
|
|
18
|
+
for (const k of list) {
|
|
19
|
+
await page.evaluate(([j, kk]) => window.sagadeck.goto(j, kk), [i, k]);
|
|
20
|
+
await page.waitForTimeout(60);
|
|
21
|
+
const f = path.join(outDir, `slide-${String(i + 1).padStart(2, "0")}${steps ? `-${k}` : ""}.${jpeg ? "jpg" : "png"}`);
|
|
22
|
+
await page.screenshot(jpeg ? { path: f, type: "jpeg", quality: 82 } : { path: f });
|
|
23
|
+
files.push(f);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { files, errors };
|
|
27
|
+
} finally { await browser.close(); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Junta miniaturas em folhas (12 por folha) para revisão rápida
|
|
31
|
+
export async function contactSheet(files, outDir, { perSheet = 12, cols = 3 } = {}) {
|
|
32
|
+
const { chromium } = await import("playwright-core");
|
|
33
|
+
const { findBrowser } = await import("./browser.js");
|
|
34
|
+
const browser = await chromium.launch({ executablePath: findBrowser() });
|
|
35
|
+
try {
|
|
36
|
+
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
|
37
|
+
const out = [];
|
|
38
|
+
for (let s = 0; s < files.length; s += perSheet) {
|
|
39
|
+
const chunk = files.slice(s, s + perSheet);
|
|
40
|
+
const cells = chunk.map((f) => `<figure><img src="data:image/png;base64,${fs.readFileSync(f).toString("base64")}"><figcaption>${path.basename(f, ".png").replace("slide-", "")}</figcaption></figure>`).join("");
|
|
41
|
+
await page.setContent(`<style>body{margin:0;background:#1a1a1a;font:600 22px system-ui;color:#ddd}main{display:grid;grid-template-columns:repeat(${cols},1fr);gap:18px;padding:18px}figure{margin:0;position:relative}img{width:100%;display:block;border-radius:6px}figcaption{position:absolute;left:8px;top:6px;background:#000c;padding:2px 8px;border-radius:5px}</style><main>${cells}</main>`);
|
|
42
|
+
await waitForResources(page);
|
|
43
|
+
const f = path.join(outDir, `folha-${Math.floor(s / perSheet) + 1}.png`);
|
|
44
|
+
await page.screenshot({ path: f, fullPage: true });
|
|
45
|
+
out.push(f);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
} finally { await browser.close(); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function pdf(htmlFile, outFile) {
|
|
52
|
+
const { browser, page } = await openDeck(htmlFile, { scale: 1 });
|
|
53
|
+
try {
|
|
54
|
+
const n = await page.evaluate(() => window.sagadeck.n);
|
|
55
|
+
const imgs = [], links = [];
|
|
56
|
+
for (let i = 0; i < n; i++) {
|
|
57
|
+
const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
|
|
58
|
+
await page.evaluate(([j, k]) => window.sagadeck.goto(j, k), [i, S]);
|
|
59
|
+
await page.waitForTimeout(50);
|
|
60
|
+
imgs.push((await page.screenshot({ type: "jpeg", quality: 92 })).toString("base64"));
|
|
61
|
+
links.push(await page.evaluate(gotoRects, i));
|
|
62
|
+
}
|
|
63
|
+
// cada página é a foto do slide; por cima, áreas clicáveis nos itens com goto (o PDF pula para a página do destino)
|
|
64
|
+
const hit = (l) => `<a href="#p${l.to}" style="position:absolute;left:${l.x}px;top:${l.y}px;width:${l.w}px;height:${l.h}px"></a>`;
|
|
65
|
+
const { w: W, h: H } = await page.evaluate(() => window.sagadeck.size || { w: 1920, h: 1080 }); // deck.aspect
|
|
66
|
+
await page.setContent(`<style>@page{size:${W}px ${H}px;margin:0}body{margin:0}.pg{position:relative;width:${W}px;height:${H}px;page-break-after:always;overflow:hidden}img{width:${W}px;height:${H}px;display:block}</style>${imgs.map((b, i) => `<div class="pg" id="p${i}"><img src="data:image/jpeg;base64,${b}">${links[i].map(hit).join("")}</div>`).join("")}`);
|
|
67
|
+
await waitForResources(page);
|
|
68
|
+
await page.pdf({ path: outFile, width: `${W}px`, height: `${H}px`, printBackground: true });
|
|
69
|
+
} finally { await browser.close(); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Itens com goto do slide i (dentro do navegador): retângulo em pixels do slide e o índice do slide de destino
|
|
73
|
+
function gotoRects(i) {
|
|
74
|
+
const all = [...document.querySelectorAll("#stage > .slide")], s = all[i], sr = s.getBoundingClientRect(), k = sr.width / 1920 || 1;
|
|
75
|
+
const idx = (t) => { t = String(t); const j = all.findIndex((x) => x.dataset.id === t); return j >= 0 ? j : /^\d+$/.test(t) ? +t - 1 : -1; };
|
|
76
|
+
return [...s.querySelectorAll("[data-goto]")].map((a) => { const r = a.getBoundingClientRect(); return { x: Math.round((r.left - sr.left) / k), y: Math.round((r.top - sr.top) / k), w: Math.round(r.width / k), h: Math.round(r.height / k), to: idx(a.dataset.goto) }; })
|
|
77
|
+
.filter((l) => l.to >= 0 && l.w > 0 && l.h > 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------- verificação (roda dentro do navegador) ----------
|
|
81
|
+
// (exportado: a transformação usa o mesmo fiscal para conferir os slides que a IA escreveu, src/studio/snapshot.js)
|
|
82
|
+
export function inPageCheck(i) {
|
|
83
|
+
const slide = document.querySelectorAll("#stage > .slide")[i];
|
|
84
|
+
const issues = [];
|
|
85
|
+
const R = (e) => e.getBoundingClientRect();
|
|
86
|
+
const lum = (c) => {
|
|
87
|
+
const m = c.match(/[\d.]+/g); if (!m) return 1;
|
|
88
|
+
const unit = /^color\(srgb/.test(c) ? 1 : 255; // color-mix() devolve color(srgb 0.95 0.95 0.96), de 0 a 1
|
|
89
|
+
const [r, g, b] = m.slice(0, 3).map((v) => { v = v / unit; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
|
|
90
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
91
|
+
};
|
|
92
|
+
const opaque = (c) => c && c !== "transparent" && c !== "none" && /^(rgb|color\(srgb)/.test(c) && !/rgba\(.*,\s*0\)$/.test(c) && !/\/\s*0\)$/.test(c);
|
|
93
|
+
// o que está de fato embaixo do texto: o desenho (pílula do infográfico em SVG) ou a caixa com fundo; gradiente ou
|
|
94
|
+
// imagem: não dá para medir (null, sem aviso). Sem nada embaixo, o fundo dos pais.
|
|
95
|
+
// (durante a medição, texto e desenho respondem ao ponteiro: muitos vêm com pointer-events: none e sumiriam da pilha)
|
|
96
|
+
let pe = document.getElementById("sd-check-pe");
|
|
97
|
+
if (!pe) { pe = document.createElement("style"); pe.id = "sd-check-pe"; pe.textContent = ".slide .t, .slide svg, .slide svg *{pointer-events:visiblePainted!important}"; document.head.appendChild(pe); }
|
|
98
|
+
const underOf = (e) => {
|
|
99
|
+
const r = R(e);
|
|
100
|
+
let below = false; // só o que vem DEPOIS do próprio texto na pilha (o que está por cima dele não é fundo)
|
|
101
|
+
for (const el of document.elementsFromPoint(r.left + r.width / 2, r.top + r.height / 2)) {
|
|
102
|
+
if (el === e || e.contains(el)) { below = true; continue; }
|
|
103
|
+
if (!below) continue;
|
|
104
|
+
const cs = getComputedStyle(el);
|
|
105
|
+
if (el instanceof SVGGeometryElement) { if (opaque(cs.fill)) return cs.fill; if (/url\(/.test(cs.fill)) return null; continue; }
|
|
106
|
+
if (el instanceof SVGElement) continue;
|
|
107
|
+
if (cs.backgroundImage && cs.backgroundImage !== "none") return null;
|
|
108
|
+
if (opaque(cs.backgroundColor)) return cs.backgroundColor;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
};
|
|
112
|
+
const bgOf = (e) => {
|
|
113
|
+
const u = underOf(e);
|
|
114
|
+
if (u !== undefined) return u;
|
|
115
|
+
for (let p = e; p; p = p.parentElement) {
|
|
116
|
+
const cs = getComputedStyle(p); const bg = cs.backgroundColor;
|
|
117
|
+
if (bg && !/rgba\(.*,\s*0\)$/.test(bg) && bg !== "transparent") return bg;
|
|
118
|
+
}
|
|
119
|
+
return "rgb(255,255,255)";
|
|
120
|
+
};
|
|
121
|
+
const visible = (e) => { const cs = getComputedStyle(e); return cs.visibility !== "hidden" && cs.display !== "none" && +cs.opacity > 0.05 && R(e).width > 0; };
|
|
122
|
+
const label = (e) => (e.textContent || "").trim().replace(/\s+/g, " ").slice(0, 50);
|
|
123
|
+
const texts = Array.from(slide.querySelectorAll(".t")).filter((e) => visible(e) && !e.closest("svg") && e.textContent.trim());
|
|
124
|
+
const safe = slide.querySelector(".safe");
|
|
125
|
+
for (const e of texts) {
|
|
126
|
+
const r = R(e);
|
|
127
|
+
const fsz = parseFloat(getComputedStyle(e).fontSize);
|
|
128
|
+
// tolerância pela letra: o enchimento do marca-texto (==x==) passa uns px da caixa sem cortar nada
|
|
129
|
+
if (e.scrollWidth > e.clientWidth + Math.max(3, fsz * 0.12)) issues.push({ kind: "estouro-horizontal", text: label(e) });
|
|
130
|
+
if (getComputedStyle(e).maxHeight !== "none" && e.scrollHeight > e.clientHeight + fsz * 0.3) issues.push({ kind: "estouro-vertical", text: label(e) });
|
|
131
|
+
if (r.right > 1920 + 2 || r.bottom > (window.sagadeck?.size?.h || 1080) + 2 || r.left < -2 || r.top < -2) issues.push({ kind: "fora-do-slide", text: label(e) });
|
|
132
|
+
if (safe && !e.closest(".foot, .headbar") && r.bottom > R(safe).bottom + 24) issues.push({ kind: "passa-da-margem-inferior", text: label(e), px: Math.round(r.bottom - R(safe).bottom) });
|
|
133
|
+
const fs = fsz;
|
|
134
|
+
if (fs < 19 && !e.closest(".foot, .headbar")) issues.push({ kind: "fonte-pequena", text: label(e), px: Math.round(fs) });
|
|
135
|
+
const bg = bgOf(e);
|
|
136
|
+
if (bg) {
|
|
137
|
+
const c1 = lum(getComputedStyle(e).color), c2 = lum(bg);
|
|
138
|
+
const ratio = (Math.max(c1, c2) + 0.05) / (Math.min(c1, c2) + 0.05);
|
|
139
|
+
if (ratio < 3) issues.push({ kind: "baixo-contraste", text: label(e), ratio: +ratio.toFixed(2) });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// texto por cima de uma tabela (as células não são .t)
|
|
143
|
+
for (const tb of slide.querySelectorAll(".dtable-wrap")) {
|
|
144
|
+
const rb = R(tb);
|
|
145
|
+
if (!rb.width) continue;
|
|
146
|
+
for (const A of texts) {
|
|
147
|
+
if (tb.contains(A) || A.contains(tb) || A.closest("[data-exit]")) continue;
|
|
148
|
+
const ra = R(A), ix = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left), iy = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
|
149
|
+
if (ix > 8 && iy > 8) issues.push({ kind: "sobreposicao", text: `${label(A)} ⟂ tabela` });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// sobreposição entre blocos de texto que não são pai/filho
|
|
153
|
+
for (let a = 0; a < texts.length; a++) for (let b = a + 1; b < texts.length; b++) {
|
|
154
|
+
const A = texts[a], B = texts[b];
|
|
155
|
+
if (A.contains(B) || B.contains(A)) continue;
|
|
156
|
+
const ra = R(A), rb = R(B);
|
|
157
|
+
const ix = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
|
|
158
|
+
const iy = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
|
159
|
+
if (ix > 8 && iy > 8) {
|
|
160
|
+
const exA = A.closest("[data-exit]"), exB = B.closest("[data-exit]");
|
|
161
|
+
if (exA || exB) continue; // trocas por clique são intencionais
|
|
162
|
+
issues.push({ kind: "sobreposicao", text: `${label(A)} ⟂ ${label(B)}` });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
pe.remove();
|
|
166
|
+
return issues;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function check(htmlFile) {
|
|
170
|
+
const { browser, page, errors } = await openDeck(htmlFile);
|
|
171
|
+
try {
|
|
172
|
+
const n = await page.evaluate(() => window.sagadeck.n);
|
|
173
|
+
const report = [];
|
|
174
|
+
for (let i = 0; i < n; i++) {
|
|
175
|
+
const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
|
|
176
|
+
await page.evaluate(([j, k]) => window.sagadeck.goto(j, k), [i, S]);
|
|
177
|
+
const issues = await page.evaluate(inPageCheck, i);
|
|
178
|
+
if (issues.length) report.push({ slide: i + 1, issues });
|
|
179
|
+
}
|
|
180
|
+
return { report, errors };
|
|
181
|
+
} finally { await browser.close(); }
|
|
182
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// Gráficos em SVG (animados no HTML; bar/column/line/donut viram gráfico NATIVO no PowerPoint).
|
|
2
|
+
//
|
|
3
|
+
// { chart: bar, data: [{label: "Médicos", value: 74}, …], suffix: "%", highlight: [2] }
|
|
4
|
+
// { chart: column, data: […], max: 100 }
|
|
5
|
+
// { chart: column, labels: [2024, 2025], series: [{name: "Receita", values: [10, 14]}, {name: "Custo", values: [8, 9]}] } (agrupado; bar também)
|
|
6
|
+
// { chart: line, labels: [...], series: [{name, values: [...]}], annotations: [{at: 5, text: "lanche"}], min: 0, max: 80 }
|
|
7
|
+
// { chart: donut, value: 79, label: "menos acidentes" } (ou parts: [{label, value}])
|
|
8
|
+
// { chart: waffle, total: 100, cols: 10, groups: [{count: 21, label: "…"}, {count: 79, label: "…"}] }
|
|
9
|
+
// { chart: isotype, total: 20, highlight: 4, icon: car } (ícone repetido)
|
|
10
|
+
// { chart: stacked, data: [{label, value}, …] }
|
|
11
|
+
//
|
|
12
|
+
// Cores: por padrão usam o tom do slide (--cb base, --ca destaque, --s1..--s5 séries).
|
|
13
|
+
// Qualquer item aceita color: hi | em | fg | muted | "FF0000".
|
|
14
|
+
import { esc } from "../markup.js";
|
|
15
|
+
import { iconSVG } from "./icons.js";
|
|
16
|
+
import { humanBody } from "./pictos.js";
|
|
17
|
+
|
|
18
|
+
const cvar = (c, d) => (!c ? `var(--${d})` : /^#?[0-9a-f]{6}$/i.test(c) ? `#${String(c).replace("#", "")}` : `var(--${c})`);
|
|
19
|
+
const fmt = (v, o) => {
|
|
20
|
+
if (v == null || isNaN(v)) return "";
|
|
21
|
+
const d = o.decimals ?? (Math.abs(v) < 10 && v % 1 ? 1 : 0);
|
|
22
|
+
const s = Number(v).toLocaleString("pt-BR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
|
23
|
+
return `${o.prefix || ""}${s}${o.suffix || ""}`;
|
|
24
|
+
};
|
|
25
|
+
const norm = (o) => {
|
|
26
|
+
if (Array.isArray(o.data)) return o.data.map((d) => (typeof d === "number" ? { label: "", value: d } : d));
|
|
27
|
+
if (o.labels && o.values) return o.labels.map((l, i) => ({ label: l, value: o.values[i] }));
|
|
28
|
+
if (o.labels && o.series?.length === 1) return o.labels.map((l, i) => ({ label: l, value: o.series[0].values?.[i] ?? 0 }));
|
|
29
|
+
return [];
|
|
30
|
+
};
|
|
31
|
+
// várias séries (colada do Excel com mais de uma coluna de números): barras/colunas agrupadas
|
|
32
|
+
const grouped = (o) => !Array.isArray(o.data) && Array.isArray(o.labels) && Array.isArray(o.series) && o.series.length > 1;
|
|
33
|
+
// nomes dos eixos (xLabel embaixo, yLabel na vertical à esquerda): cabem nas margens que o gráfico reserva
|
|
34
|
+
const axisTitles = (o, W, H, left = 0) => `${o.xLabel ? `<text x="${left + (W - left) / 2}" y="${H - 6}" text-anchor="middle" class="ch-axis f-label" font-size="22" style="fill:var(--muted)">${esc(o.xLabel)}</text>` : ""}${o.yLabel ? `<text x="0" y="0" transform="translate(22 ${H / 2}) rotate(-90)" text-anchor="middle" class="ch-axis f-label" font-size="22" style="fill:var(--muted)">${esc(o.yLabel)}</text>` : ""}`;
|
|
35
|
+
const seriesColor = (s, i) => (s.color ? cvar(s.color) : `var(--s${(i % 5) + 1})`);
|
|
36
|
+
function legendSVG(series, W, y = 30) {
|
|
37
|
+
let x = W, g = "";
|
|
38
|
+
[...series].reverse().forEach((s, ri) => {
|
|
39
|
+
const i = series.length - 1 - ri, name = String(s.name || `Série ${i + 1}`);
|
|
40
|
+
x -= textW(name, 24) + 60;
|
|
41
|
+
g += `<rect x="${x}" y="${y - 20}" width="24" height="24" rx="5" style="fill:${seriesColor(s, i)}"/><text x="${x + 34}" y="${y}" class="f-label" font-size="24" style="fill:var(--muted)">${esc(name)}</text>`;
|
|
42
|
+
});
|
|
43
|
+
return g;
|
|
44
|
+
}
|
|
45
|
+
const isHL = (o, d, i) => {
|
|
46
|
+
const h = o.highlight;
|
|
47
|
+
if (h == null) return false;
|
|
48
|
+
const arr = Array.isArray(h) ? h : [h];
|
|
49
|
+
return arr.includes(i) || arr.includes(d.label) || d.highlight;
|
|
50
|
+
};
|
|
51
|
+
// ---- rótulos que cabem (todo texto de gráfico tem uma largura para respeitar) ----
|
|
52
|
+
// largura aproximada por caractere, em "em": texto comum ~0,62 (fontes largas como Century Gothic);
|
|
53
|
+
// rótulos f-label são MAIÚSCULOS e espaçados, bem mais largos (~0,86). O fit.js mede de verdade depois.
|
|
54
|
+
const EM = { text: 0.62, caps: 0.86 };
|
|
55
|
+
const textW = (s, fs, k = EM.text) => String(s ?? "").length * fs * k;
|
|
56
|
+
// reduz a fonte até `minFs`; se ainda não couber, quebra em 2 linhas (no espaço mais perto do meio)
|
|
57
|
+
function fitLabel(label, maxW, fs, minFs = 22, k = EM.text) {
|
|
58
|
+
const s = String(label ?? "");
|
|
59
|
+
const one = Math.min(fs, maxW / Math.max(1, s.length * k));
|
|
60
|
+
if (one >= minFs || !/\s/.test(s)) return { fs: Math.max(12, Math.round(one)), lines: [s] };
|
|
61
|
+
const words = s.split(/\s+/);
|
|
62
|
+
let best = [s, ""], longest = Infinity;
|
|
63
|
+
for (let k = 1; k < words.length; k++) {
|
|
64
|
+
const a = words.slice(0, k).join(" "), b = words.slice(k).join(" ");
|
|
65
|
+
if (Math.max(a.length, b.length) < longest) { longest = Math.max(a.length, b.length); best = [a, b]; }
|
|
66
|
+
}
|
|
67
|
+
return { fs: Math.max(12, Math.round(Math.min(fs, maxW / (longest * k)))), lines: best };
|
|
68
|
+
}
|
|
69
|
+
// rótulos do mesmo eixo com o mesmo tamanho (o menor que coube): tamanhos diferentes parecem erro
|
|
70
|
+
const sameSize = (lbls) => { const fs = Math.min(...lbls.map((l) => l.fs)); lbls.forEach((l) => (l.fs = fs)); return lbls; };
|
|
71
|
+
// <text> com 1 ou 2 linhas; `y` é a linha de base da PRIMEIRA linha (2 linhas sobem meia linha)
|
|
72
|
+
function labelSVG(lbl, x, y, attrs, center = true) {
|
|
73
|
+
const lh = lbl.fs * 1.12, y0 = center && lbl.lines.length > 1 ? y - lh / 2 : y;
|
|
74
|
+
const body = lbl.lines.length > 1
|
|
75
|
+
? lbl.lines.map((l, i) => `<tspan x="${x}" y="${y0 + i * lh}">${esc(l)}</tspan>`).join("")
|
|
76
|
+
: esc(lbl.lines[0]);
|
|
77
|
+
return `<text x="${x}" y="${y0}" font-size="${lbl.fs}" ${attrs}>${body}</text>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const wrap = (w, h, inner, o, type) =>
|
|
81
|
+
`<svg class="chart chart-${type}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" data-chart='${esc(JSON.stringify(nativeSpec(o, type)))}'>${inner}</svg>`;
|
|
82
|
+
|
|
83
|
+
// especificação usada pelo exportador de PPTX para montar gráfico nativo
|
|
84
|
+
function nativeSpec(o, type) {
|
|
85
|
+
if ((type === "bar" || type === "column") && grouped(o)) return { type, labels: o.labels, series: o.series.map((s) => ({ name: s.name || "", values: s.values })), suffix: o.suffix || "", prefix: o.prefix || "", max: o.max };
|
|
86
|
+
if (type === "bar" || type === "column") {
|
|
87
|
+
const d = norm(o);
|
|
88
|
+
return { type, labels: d.map((x) => x.label), values: d.map((x) => x.value), suffix: o.suffix || "", prefix: o.prefix || "", max: o.max, highlight: d.map((x, i) => !!isHL(o, x, i)) };
|
|
89
|
+
}
|
|
90
|
+
if (type === "line") return { type, labels: o.labels, series: o.series.map((s) => ({ name: s.name || "", values: s.values })), min: o.min, max: o.max, suffix: o.suffix || "" };
|
|
91
|
+
if (type === "donut") return { type, parts: o.parts || [{ label: o.label || "", value: o.value }, { label: "", value: (o.total || 100) - o.value }] };
|
|
92
|
+
return { type: "image" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function barGrouped(o, W, H) {
|
|
96
|
+
const { labels, series } = o, k = series.length;
|
|
97
|
+
const all = series.flatMap((s) => s.values).filter((v) => v != null);
|
|
98
|
+
const max = o.max ?? Math.max(...all) * 1.08, top = 50;
|
|
99
|
+
const fs0 = o.fontSize || 34;
|
|
100
|
+
const lw = o.labelWidth ?? Math.round(Math.min(W * 0.4, Math.max(W * 0.18, Math.max(0, ...labels.map((l) => textW(l, fs0))) + 40)));
|
|
101
|
+
const vw = 140, rowH = (H - top) / labels.length, bh = Math.min(o.barHeight || 60, (rowH * 0.78) / k);
|
|
102
|
+
let g = legendSVG(series, W);
|
|
103
|
+
const lbls = sameSize(labels.map((l) => fitLabel(l, lw - 30, fs0)));
|
|
104
|
+
labels.forEach((_, i) => {
|
|
105
|
+
const y0 = top + i * rowH + (rowH - bh * k) / 2;
|
|
106
|
+
g += labelSVG(lbls[i], lw - 24, y0 + (bh * k) / 2 + 12, `text-anchor="end" class="f-heading" style="fill:var(--fg)"`);
|
|
107
|
+
series.forEach((s, j) => {
|
|
108
|
+
const v = s.values[i]; if (v == null) return;
|
|
109
|
+
const y = y0 + j * bh, bw = Math.max(4, ((W - lw - vw) * v) / max);
|
|
110
|
+
g += `<rect class="gx" style="--i:${i};fill:${seriesColor(s, j)}" x="${lw}" y="${y}" width="${bw}" height="${bh - 4}" rx="${Math.min(6, bh / 4)}"/>`;
|
|
111
|
+
g += `<text class="fade-in f-display" style="--i:${i};fill:var(--fg)" x="${lw + bw + 14}" y="${y + bh / 2 + 10}" font-size="${o.valueSize || Math.min(34, bh * 0.8)}">${esc(fmt(v, o))}</text>`;
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
return wrap(W, H, g, o, "bar");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function columnGrouped(o, W, H) {
|
|
118
|
+
const { labels, series } = o, k = series.length;
|
|
119
|
+
const all = series.flatMap((s) => s.values).filter((v) => v != null);
|
|
120
|
+
const max = o.max ?? Math.max(...all) * 1.12;
|
|
121
|
+
const top = 110, bottom = o.xLabel ? 130 : 90, ch = H - top - bottom, slot = W / labels.length;
|
|
122
|
+
const bw = Math.min(o.barWidth || 120, (slot * 0.8) / k);
|
|
123
|
+
let g = legendSVG(series, W) + `<line x1="0" y1="${top + ch}" x2="${W}" y2="${top + ch}" style="stroke:var(--line)" stroke-width="3"/>`;
|
|
124
|
+
const lbls = sameSize(labels.map((l) => fitLabel(l, slot * 0.94, o.fontSize || 30)));
|
|
125
|
+
labels.forEach((_, i) => {
|
|
126
|
+
const x0 = i * slot + (slot - bw * k) / 2;
|
|
127
|
+
series.forEach((s, j) => {
|
|
128
|
+
const v = s.values[i]; if (v == null) return;
|
|
129
|
+
const bh = Math.max(4, (ch * v) / max), X = x0 + j * bw, Y = top + ch - bh;
|
|
130
|
+
g += `<rect class="gy" style="--i:${i};fill:${seriesColor(s, j)}" x="${X + 3}" y="${Y}" width="${bw - 6}" height="${bh}" rx="5"/>`;
|
|
131
|
+
g += `<text class="fade-in f-display" style="--i:${i};fill:var(--fg)" x="${X + bw / 2}" y="${Y - 14}" text-anchor="middle" font-size="${o.valueSize || Math.min(40, Math.max(20, bw * 0.4))}">${esc(fmt(v, o))}</text>`;
|
|
132
|
+
});
|
|
133
|
+
g += labelSVG(lbls[i], i * slot + slot / 2, top + ch + 52, `text-anchor="middle" class="f-heading" style="fill:var(--fg)"`, false);
|
|
134
|
+
});
|
|
135
|
+
return wrap(W, H, g + axisTitles(o, W, H), o, "column");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function bar(o, W, H) {
|
|
139
|
+
if (grouped(o)) return barGrouped(o, W, H);
|
|
140
|
+
const d = norm(o);
|
|
141
|
+
const max = o.max ?? Math.max(...d.map((x) => x.value)) * 1.08;
|
|
142
|
+
const fs0 = o.fontSize || 36;
|
|
143
|
+
const longest = Math.max(0, ...d.map((x) => textW(x.label, fs0)));
|
|
144
|
+
const lw = o.labelWidth ?? Math.round(Math.min(W * 0.45, Math.max(W * 0.22, longest + 40)));
|
|
145
|
+
const vw = 150;
|
|
146
|
+
const n = d.length, rowH = H / n, bh = Math.min(o.barHeight || 84, rowH * 0.64);
|
|
147
|
+
let g = "";
|
|
148
|
+
const lbls = sameSize(d.map((x) => fitLabel(x.label, lw - 30, fs0)));
|
|
149
|
+
d.forEach((x, i) => {
|
|
150
|
+
const y = i * rowH + (rowH - bh) / 2;
|
|
151
|
+
const bw = Math.max(4, ((W - lw - vw) * x.value) / max);
|
|
152
|
+
const hl = isHL(o, x, i);
|
|
153
|
+
const color = x.color ? cvar(x.color) : hl ? "var(--ca)" : "var(--cb)";
|
|
154
|
+
g += labelSVG(lbls[i], lw - 24, y + bh / 2 + 12, `text-anchor="end" class="f-heading" style="fill:var(--fg)"`);
|
|
155
|
+
g += `<rect class="gx" style="--i:${i};fill:${color}" x="${lw}" y="${y}" width="${bw}" height="${bh}" rx="${Math.min(8, bh / 4)}"/>`;
|
|
156
|
+
g += `<text class="fade-in f-display" style="--i:${i};fill:var(--fg)" x="${lw + bw + 18}" y="${y + bh / 2 + 16}" font-size="${o.valueSize || 46}">${esc(fmt(x.value, o))}</text>`;
|
|
157
|
+
});
|
|
158
|
+
return wrap(W, H, g, o, "bar");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function column(o, W, H) {
|
|
162
|
+
if (grouped(o)) return columnGrouped(o, W, H);
|
|
163
|
+
const d = norm(o);
|
|
164
|
+
const max = o.max ?? Math.max(...d.map((x) => x.value)) * 1.1;
|
|
165
|
+
const n = d.length, top = 80, bottom = o.xLabel ? 130 : 90, ch = H - top - bottom;
|
|
166
|
+
const slot = W / n, bw = Math.min(o.barWidth || 220, slot * 0.62);
|
|
167
|
+
let g = `<line x1="0" y1="${top + ch}" x2="${W}" y2="${top + ch}" style="stroke:var(--line)" stroke-width="3"/>`;
|
|
168
|
+
const lbls = sameSize(d.map((x) => fitLabel(x.label, slot * 0.94, o.fontSize || 32)));
|
|
169
|
+
d.forEach((x, i) => {
|
|
170
|
+
const bh = Math.max(4, (ch * x.value) / max);
|
|
171
|
+
const X = i * slot + (slot - bw) / 2, Y = top + ch - bh;
|
|
172
|
+
const hl = isHL(o, x, i);
|
|
173
|
+
const color = x.color ? cvar(x.color) : hl ? "var(--ca)" : "var(--cb)";
|
|
174
|
+
g += `<rect class="gy" style="--i:${i};fill:${color}" x="${X}" y="${Y}" width="${bw}" height="${bh}" rx="6"/>`;
|
|
175
|
+
g += `<text class="fade-in f-display" style="--i:${i};fill:var(--fg)" x="${X + bw / 2}" y="${Y - 18}" text-anchor="middle" font-size="${o.valueSize || 56}">${esc(fmt(x.value, o))}</text>`;
|
|
176
|
+
g += labelSVG(lbls[i], X + bw / 2, top + ch + 52, `text-anchor="middle" class="f-heading" style="fill:var(--fg)"`, false);
|
|
177
|
+
});
|
|
178
|
+
return wrap(W, H, g + axisTitles(o, W, H), o, "column");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// marcações "redondas" do eixo (passo 1, 2, 2,5 ou 5 × 10^k, umas 4 a 6): arredondar para inteiro deixava um eixo de
|
|
182
|
+
// 0 a 1 só com "0" e "1"
|
|
183
|
+
export function niceTicks(min, max, want = 5) {
|
|
184
|
+
if (!(max > min)) return [min];
|
|
185
|
+
const raw = (max - min) / want, mag = 10 ** Math.floor(Math.log10(raw));
|
|
186
|
+
const step = [1, 2, 2.5, 5, 10].map((k) => k * mag).find((x) => x >= raw);
|
|
187
|
+
const out = [];
|
|
188
|
+
for (let v = Math.ceil(min / step - 1e-9) * step; v <= max + 1e-9; v += step) out.push(+v.toPrecision(12));
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function line(o, W, H) {
|
|
192
|
+
const series = o.series || [{ values: o.values }];
|
|
193
|
+
// rótulo que veio como número (0.5): com vírgula, como o resto do gráfico
|
|
194
|
+
const labels = (o.labels || series[0].values.map((_, i) => String(i + 1))).map((l) => (typeof l === "number" ? l.toLocaleString("pt-BR", { useGrouping: false }) : l));
|
|
195
|
+
const all = series.flatMap((s) => s.values).filter((v) => v != null);
|
|
196
|
+
const min = o.min ?? Math.min(0, ...all), max = o.max ?? Math.max(...all) * 1.1;
|
|
197
|
+
// nomes no fim das linhas, a não ser que se encostem (as linhas terminam juntas) ou o slide peça legenda: aí, legenda em cima
|
|
198
|
+
const ends = series.length > 1 ? series.map((s) => s.values.filter((v) => v != null).at(-1)) : [];
|
|
199
|
+
const useLegend = series.length > 1 && (o.legend === true || ends.some((a, i) => ends.some((b, j) => j > i && Math.abs(a - b) <= (max - min) * 0.08)));
|
|
200
|
+
const L = (o.axis === false ? 10 : 90) + (o.yLabel ? 40 : 0), R = 40, T = useLegend ? 80 : 50, B = o.xLabel ? 110 : 70;
|
|
201
|
+
const cw = W - L - R, ch = H - T - B, n = labels.length;
|
|
202
|
+
const X = (i) => L + (n === 1 ? cw / 2 : (cw * i) / (n - 1));
|
|
203
|
+
const Y = (v) => T + ch - ((v - min) / (max - min)) * ch;
|
|
204
|
+
let g = useLegend ? legendSVG(series, W) : "";
|
|
205
|
+
const ticks = o.ticks ?? niceTicks(min, max);
|
|
206
|
+
// casas do passo (0,25 não vira "0,3"); o decimals do slide vale
|
|
207
|
+
const tickDec = Math.min(4, Math.max(0, ...ticks.map((t) => (String(t).split(".")[1] || "").length)));
|
|
208
|
+
if (o.axis !== false) ticks.forEach((t) => {
|
|
209
|
+
g += `<line x1="${L}" x2="${W - R}" y1="${Y(t)}" y2="${Y(t)}" style="stroke:var(--line)" stroke-width="2"/>`;
|
|
210
|
+
g += `<text x="${L - 16}" y="${Y(t) + 9}" text-anchor="end" class="f-label" font-size="22" style="fill:var(--muted)">${esc(fmt(t, { decimals: tickDec, ...o }))}</text>`;
|
|
211
|
+
});
|
|
212
|
+
const gap = n > 1 ? cw / (n - 1) : cw;
|
|
213
|
+
const axis = labels.map((l, i) => {
|
|
214
|
+
if (!l) return null;
|
|
215
|
+
const edge = n > 1 && (i === 0 || i === n - 1);
|
|
216
|
+
const anchor = !edge ? "middle" : i === 0 ? "start" : "end";
|
|
217
|
+
// cada rótulo usa só a SUA metade do vão para o lado do vizinho; as pontas podem avançar na margem externa
|
|
218
|
+
const x = !edge ? X(i) : i === 0 ? Math.max(4, X(i) - (L - 14)) : Math.min(W - 4, X(i) + (R - 4));
|
|
219
|
+
const room = !edge ? gap * 0.9 : Math.abs(X(i) - x) + gap * 0.45;
|
|
220
|
+
return { anchor, x, lbl: fitLabel(l, room, 22, 15, EM.caps) };
|
|
221
|
+
});
|
|
222
|
+
sameSize(axis.filter(Boolean).map((a) => a.lbl));
|
|
223
|
+
axis.forEach((a) => {
|
|
224
|
+
if (!a) return;
|
|
225
|
+
g += labelSVG(a.lbl, a.x, H - (o.xLabel ? 64 : 24) - (a.lbl.lines.length - 1) * a.lbl.fs * 1.12, `text-anchor="${a.anchor}" class="f-label" style="fill:var(--muted)"`, false);
|
|
226
|
+
});
|
|
227
|
+
(o.bands || []).forEach((b) => {
|
|
228
|
+
g += `<line x1="${X(b.at)}" x2="${X(b.at)}" y1="${T - 20}" y2="${T + ch}" style="stroke:var(--em)" stroke-width="3" stroke-dasharray="10 10"/>`;
|
|
229
|
+
if (b.text) g += `<text x="${X(b.at) + 12}" y="${T - 4}" class="f-label" font-size="22" style="fill:var(--em)">${esc(b.text)}</text>`;
|
|
230
|
+
});
|
|
231
|
+
series.forEach((s, si) => {
|
|
232
|
+
const color = s.color ? cvar(s.color) : `var(--s${si + 1})`;
|
|
233
|
+
// quebra em segmentos nos null
|
|
234
|
+
let segs = [[]];
|
|
235
|
+
s.values.forEach((v, i) => { if (v == null) segs.push([]); else segs[segs.length - 1].push([X(i), Y(v)]); });
|
|
236
|
+
segs = segs.filter((sg) => sg.length);
|
|
237
|
+
segs.forEach((sg, k) => {
|
|
238
|
+
const d = sg.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" ");
|
|
239
|
+
if (o.area) g += `<path class="fade-in" style="--i:${k};fill:${color}" opacity=".14" d="${d} L${sg[sg.length - 1][0]} ${T + ch} L${sg[0][0]} ${T + ch} Z"/>`;
|
|
240
|
+
g += `<path class="draw" pathLength="1" style="--i:${si * 2 + k};stroke:${color}" d="${d}" fill="none" stroke-width="${s.width || 7}" stroke-linejoin="round" stroke-linecap="round"/>`;
|
|
241
|
+
if (o.markers !== false) sg.forEach((p, i) => {
|
|
242
|
+
const last = i === sg.length - 1;
|
|
243
|
+
g += `<circle class="fade-in" style="--i:${si * 2 + k};fill:${last ? color : "var(--bg)"};stroke:${color}" cx="${p[0]}" cy="${p[1]}" r="${last ? 11 : 7}" stroke-width="4"/>`;
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
if (series.length > 1 && s.name && !useLegend) {
|
|
247
|
+
// rótulo no fim da linha: a série mais alta ali ganha o rótulo acima; as outras, abaixo
|
|
248
|
+
const lastIdx = s.values.map((v, i) => (v != null ? i : -1)).filter((i) => i >= 0).pop();
|
|
249
|
+
const endVals = series.map((x) => x.values[lastIdx] ?? -Infinity);
|
|
250
|
+
const isTop = s.values[lastIdx] >= Math.max(...endVals);
|
|
251
|
+
g += `<text x="${X(lastIdx)}" y="${Y(s.values[lastIdx]) + (isTop ? -26 : 46)}" text-anchor="end" class="f-label" font-size="24" style="fill:${color}">${esc(s.name)}</text>`;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
(o.annotations || []).forEach((a, i) => {
|
|
255
|
+
const s = series[a.series || 0];
|
|
256
|
+
const v = a.value ?? s.values[a.at];
|
|
257
|
+
const x = X(a.at), y = Y(v);
|
|
258
|
+
const dy = a.dy ?? -60;
|
|
259
|
+
g += `<g class="fade-in" style="--i:${6 + i}"><line x1="${x}" y1="${y}" x2="${x}" y2="${y + dy + 14}" style="stroke:var(--fg)" stroke-width="2"/>
|
|
260
|
+
<text x="${x}" y="${y + dy}" text-anchor="${a.anchor || "middle"}" class="f-heading" font-size="${a.size || 30}" style="fill:var(--fg)">${esc(a.text)}</text></g>`;
|
|
261
|
+
});
|
|
262
|
+
return wrap(W, H, g + axisTitles(o, W, H, L), o, "line");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function donut(o, W, H) {
|
|
266
|
+
const parts = o.parts || [{ value: o.value, color: o.color || "ca" }, { value: (o.total || 100) - o.value, color: "cb" }];
|
|
267
|
+
const tot = parts.reduce((a, p) => a + p.value, 0);
|
|
268
|
+
const r = Math.min(W, H) / 2 - 40, cx = W / 2, cy = H / 2, sw = o.thickness || r * 0.3;
|
|
269
|
+
const C = 2 * Math.PI * r;
|
|
270
|
+
let acc = 0, g = "";
|
|
271
|
+
parts.forEach((p, i) => {
|
|
272
|
+
const frac = p.value / tot;
|
|
273
|
+
const color = cvar(p.color, i === 0 ? "ca" : `s${i + 1}`);
|
|
274
|
+
g += `<circle class="arc" pathLength="1" style="--i:${i};stroke:${color};--frac:${frac};--off:${-acc}" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="${sw}" transform="rotate(-90 ${cx} ${cy})" stroke-dasharray="${frac} ${1 - frac}" stroke-dashoffset="${-acc}"/>`;
|
|
275
|
+
acc += frac;
|
|
276
|
+
});
|
|
277
|
+
if (o.center !== false) g += `<text x="${cx}" y="${cy + (o.centerSize || r * 0.42) * 0.34}" text-anchor="middle" class="f-display" font-size="${o.centerSize || r * 0.42}" style="fill:var(--fg)">${esc(o.center ?? fmt(o.value ?? parts[0].value, { suffix: "%", ...o }))}</text>`;
|
|
278
|
+
return wrap(W, H, g, o, "donut");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function waffle(o, W, H) {
|
|
282
|
+
const total = o.total || 100, cols = o.cols || 10, rows = Math.ceil(total / cols);
|
|
283
|
+
const groups = o.groups || [{ count: o.value || 0, color: "ca" }];
|
|
284
|
+
const colorOf = (gr, gi) => cvar(gr.color, gi === 0 ? "ca" : gi === 1 ? "cb" : `s${gi + 1}`);
|
|
285
|
+
// legenda: itens em linhas, quebrando quando não cabem na largura (e cada rótulo cabe sozinho)
|
|
286
|
+
const items = o.legend === false ? [] : groups.map((gr, gi) => ({ gr, gi, lbl: fitLabel(gr.label, W - 50, 30, 20) })).filter((x) => x.gr.label);
|
|
287
|
+
const place = (x0) => {
|
|
288
|
+
const lines = [[]];
|
|
289
|
+
let lx = x0;
|
|
290
|
+
for (const it of items) {
|
|
291
|
+
const iw = 40 + Math.max(...it.lbl.lines.map((l) => textW(l, it.lbl.fs))); // f-heading
|
|
292
|
+
if (lines.at(-1).length && lx + iw > W) { lines.push([]); lx = x0; }
|
|
293
|
+
lines.at(-1).push({ ...it, x: lx });
|
|
294
|
+
lx += iw + 44;
|
|
295
|
+
}
|
|
296
|
+
return lines;
|
|
297
|
+
};
|
|
298
|
+
const lineH = (line) => Math.max(0, ...line.map((it) => it.lbl.lines.length * it.lbl.fs * 1.12)) + 14;
|
|
299
|
+
const legendH = (lines) => (items.length ? 30 + lines.reduce((h, l) => h + lineH(l), 0) : 0);
|
|
300
|
+
let lines = place(0);
|
|
301
|
+
let cell = Math.min(W / cols, (H - legendH(lines)) / rows);
|
|
302
|
+
let ox = (W - cols * cell) / 2;
|
|
303
|
+
lines = place(ox);
|
|
304
|
+
cell = Math.min(cell, (H - legendH(lines)) / rows);
|
|
305
|
+
ox = (W - cols * cell) / 2;
|
|
306
|
+
const r = cell * 0.36;
|
|
307
|
+
const colors = [];
|
|
308
|
+
groups.forEach((gr, gi) => { for (let k = 0; k < gr.count; k++) colors.push(colorOf(gr, gi)); });
|
|
309
|
+
let g = "";
|
|
310
|
+
for (let i = 0; i < total; i++) {
|
|
311
|
+
const x = ox + (i % cols) * cell + cell / 2, y = Math.floor(i / cols) * cell + cell / 2;
|
|
312
|
+
const c = colors[i] || "var(--line)";
|
|
313
|
+
g += o.shape === "square"
|
|
314
|
+
? `<rect class="pop" style="--i:${i};fill:${c}" x="${x - r}" y="${y - r}" width="${2 * r}" height="${2 * r}" rx="${r * 0.2}"/>`
|
|
315
|
+
: `<circle class="pop" style="--i:${i};fill:${c}" cx="${x}" cy="${y}" r="${r}"/>`;
|
|
316
|
+
}
|
|
317
|
+
let ly = rows * cell + 30;
|
|
318
|
+
for (const line of place(ox)) {
|
|
319
|
+
for (const it of line) {
|
|
320
|
+
const base = ly + it.lbl.fs * 0.9;
|
|
321
|
+
g += `<circle cx="${it.x + 14}" cy="${base - it.lbl.fs * 0.33}" r="14" style="fill:${colorOf(it.gr, it.gi)}"/>`;
|
|
322
|
+
g += labelSVG(it.lbl, it.x + 40, base, `class="f-heading" style="fill:var(--fg)"`, false);
|
|
323
|
+
}
|
|
324
|
+
ly += lineH(line);
|
|
325
|
+
}
|
|
326
|
+
return wrap(W, H, g, o, "waffle");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isotype(o, W, H) {
|
|
330
|
+
const total = o.total || 10, hl = o.highlight || 0, cols = o.cols || Math.min(total, 10);
|
|
331
|
+
const rows = Math.ceil(total / cols);
|
|
332
|
+
const cell = Math.min(W / cols, H / rows);
|
|
333
|
+
let g = "";
|
|
334
|
+
const ox = (W - cols * cell) / 2;
|
|
335
|
+
const glyph = o.icon ? iconSVG(o.icon, { size: cell * 0.8, stroke: o.stroke || 2 }) : null;
|
|
336
|
+
for (let i = 0; i < total; i++) {
|
|
337
|
+
const x = ox + (i % cols) * cell, y = Math.floor(i / cols) * cell;
|
|
338
|
+
const on = o.from === "end" ? i >= total - hl : i < hl;
|
|
339
|
+
const color = on ? cvar(o.color, "ca") : "var(--cb)";
|
|
340
|
+
if (glyph) g += `<g class="pop" style="--i:${i};color:${color}" transform="translate(${x + cell * 0.1} ${y + cell * 0.1})">${glyph}</g>`;
|
|
341
|
+
else g += `<g class="pop" style="--i:${i};--pc:${color}" transform="translate(${x + cell * 0.14} ${y + cell * 0.02}) scale(${cell / 170})">${humanBody(o.pose || "stand")}</g>`;
|
|
342
|
+
}
|
|
343
|
+
return wrap(W, H, g, o, "isotype");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function stacked(o, W, H) {
|
|
347
|
+
const d = norm(o);
|
|
348
|
+
const tot = d.reduce((a, x) => a + x.value, 0);
|
|
349
|
+
const bh = Math.min(o.barHeight || 120, H * 0.5);
|
|
350
|
+
let x = 0, g = "";
|
|
351
|
+
d.forEach((p, i) => {
|
|
352
|
+
const w = (W * p.value) / tot;
|
|
353
|
+
const c = cvar(p.color, i === 0 ? "ca" : i === 1 ? "cb" : `s${i + 1}`);
|
|
354
|
+
g += `<rect class="gx" style="--i:${i};fill:${c}" x="${x}" y="0" width="${w - 4}" height="${bh}" rx="6"/>`;
|
|
355
|
+
g += `<text class="fade-in f-display" style="--i:${i};fill:var(--fg)" x="${x + 6}" y="${bh + 64}" font-size="54">${esc(fmt(p.value, { suffix: o.suffix ?? "%" }))}</text>`;
|
|
356
|
+
g += `<text class="f-heading" style="fill:var(--muted)" x="${x + 6}" y="${bh + 104}" font-size="28">${esc(p.label)}</text>`;
|
|
357
|
+
x += w;
|
|
358
|
+
});
|
|
359
|
+
return wrap(W, H, g, o, "stacked");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const TYPES = { bar, column, line, donut, waffle, isotype, stacked };
|
|
363
|
+
|
|
364
|
+
// o tipo como chave, como a IA às vezes escreve: { chart: { line: { labels, series } } } ou { line: { labels, series } }
|
|
365
|
+
function unwrapType(o) {
|
|
366
|
+
if (o && o.chart && typeof o.chart === "object") {
|
|
367
|
+
const [k, v] = Object.entries(o.chart)[0] || [];
|
|
368
|
+
if (TYPES[k] && v && typeof v === "object") return { ...o, ...v, chart: k };
|
|
369
|
+
return { ...o, ...o.chart, chart: o.chart.chart || o.chart.type };
|
|
370
|
+
}
|
|
371
|
+
if (o && !TYPES[o.chart]) {
|
|
372
|
+
const k = Object.keys(TYPES).find((t) => o[t] && typeof o[t] === "object" && !Array.isArray(o[t]));
|
|
373
|
+
if (k) { const { [k]: v, ...rest } = o; return { ...rest, ...v, chart: k }; }
|
|
374
|
+
}
|
|
375
|
+
return o;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function chart(o, w = 1200, h = 620) {
|
|
379
|
+
o = unwrapType(o);
|
|
380
|
+
const fn = TYPES[o.chart];
|
|
381
|
+
if (!fn) throw new Error(o.chart ? `gráfico "${o.chart}" desconhecido (${Object.keys(TYPES).join(" | ")})` : `gráfico sem tipo: diga qual em chart (${Object.keys(TYPES).join(" | ")}), ex.: { chart: line, labels: [...], series: [...] }`);
|
|
382
|
+
return fn(o, o.w || w, o.h || h);
|
|
383
|
+
}
|