sagadeck 1.7.0 → 1.8.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.
Files changed (65) hide show
  1. package/README.md +41 -1
  2. package/SKILL.md +3 -3
  3. package/bin/sagadeck.js +2 -2
  4. package/docs/ANDAMENTO.md +162 -4
  5. package/docs/EXPERIENCIAS.md +1 -1
  6. package/docs/REFERENCIA.md +59 -1
  7. package/package.json +2 -1
  8. package/src/ai/deck-ai.js +13 -4
  9. package/src/ai/ia-config.js +17 -2
  10. package/src/ai/llm.js +6 -4
  11. package/src/ai/map-analysis-run.js +119 -0
  12. package/src/ai/map-data.js +221 -0
  13. package/src/ai/variety.js +1 -1
  14. package/src/build.js +6 -2
  15. package/src/experiences.js +1 -1
  16. package/src/export/browser.js +23 -1
  17. package/src/export/pptx.js +2 -1
  18. package/src/export/shots.js +3 -1
  19. package/src/layouts.js +10 -0
  20. package/src/library.js +2 -2
  21. package/src/map-analysis.js +106 -0
  22. package/src/map-config.js +83 -0
  23. package/src/map-routing.js +82 -0
  24. package/src/map-services.js +60 -0
  25. package/src/map.js +261 -0
  26. package/src/mcp/server.js +4 -4
  27. package/src/runtime/base.css +21 -1
  28. package/src/runtime/code-lab.js +10 -10
  29. package/src/runtime/map.js +234 -0
  30. package/src/runtime/runtime.js +1 -1
  31. package/src/runtime/skins/aluminio.css +1 -1
  32. package/src/runtime/skins/prata.css +1 -1
  33. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  34. package/src/runtime/vendor/geo-contornos.json +1 -0
  35. package/src/runtime/vendor/leaflet.css +661 -0
  36. package/src/runtime/vendor/leaflet.js +6 -0
  37. package/src/studio/ai-chat-route.js +20 -0
  38. package/src/studio/banner.js +1 -1
  39. package/src/studio/demo-decks.js +3 -1
  40. package/src/studio/geo-routes.js +138 -0
  41. package/src/studio/ia-routes.js +4 -1
  42. package/src/studio/layout-samples.js +71 -0
  43. package/src/studio/project.js +1 -1
  44. package/src/studio/public/ai-settings.js +15 -2
  45. package/src/studio/public/app.js +13 -7
  46. package/src/studio/public/chat.js +1 -1
  47. package/src/studio/public/explorer.js +23 -8
  48. package/src/studio/public/index.html +4 -0
  49. package/src/studio/public/library.css +2 -1
  50. package/src/studio/public/library.html +2 -0
  51. package/src/studio/public/library.js +10 -1
  52. package/src/studio/public/map-data.js +154 -0
  53. package/src/studio/public/map-editor.js +287 -0
  54. package/src/studio/public/map-fields.js +21 -0
  55. package/src/studio/public/map-settings.js +84 -0
  56. package/src/studio/public/slide-form.js +1 -1
  57. package/src/studio/public/style.css +13 -0
  58. package/src/studio/public/ui-icons.js +1 -1
  59. package/src/studio/public/viewers.js +2 -1
  60. package/src/studio/public/visual-editor.js +1 -1
  61. package/src/studio/server.js +13 -1
  62. package/src/studio/tour.js +76 -0
  63. package/src/templates/scaffold.js +5 -5
  64. package/src/themes.js +5 -5
  65. package/templates/{exemplo-keynote.yaml → exemplo-prata.yaml} +6 -6
@@ -0,0 +1,119 @@
1
+ // Análises do mapa pedidas pela IA no bloco `mapa:` (entrega 5): o código calcula, grava o resultado no projeto e
2
+ // devolve números para a IA narrar. mais_proximo, contar (em raio ou em área), faixa em volta de uma linha, sugerir
3
+ // onde instalar, rota com tempo, área alcançável e encaixar um rastro de GPS nas ruas. Lê as camadas do projeto (CSV
4
+ // de pontos, GeoJSON, GPX, KML). Quem chama é runMapData (src/ai/map-data.js), que já cuidou do serviço bloqueado.
5
+ import fs from "node:fs";
6
+ import * as Project from "../studio/project.js";
7
+ import { parseCSV } from "../csv.js";
8
+ import { pointsFromRows, fromGPX, fromKML } from "../map.js";
9
+ import { geocodeOne } from "../map-services.js";
10
+ import { nearest, withinRadius, inPolygon, pointLine, circle, lineBuffer, suggestSites, fmtMetros } from "../map-analysis.js";
11
+ import { route, isochrone, matchTrace, MODOS } from "../map-routing.js";
12
+
13
+ export const ANALYSES = new Set(["mais_proximo", "contar", "faixa", "sugerir", "rota", "alcance", "encaixar"]);
14
+
15
+ const okFile = (f, ext) => typeof f === "string" && /^contexto\/[\w\-. /]+$/.test(f) && f.toLowerCase().endsWith(ext) && !f.includes("..");
16
+ const nameOf = (props, i) => props?.nome || props?.name || props?.titulo || `item ${i + 1}`;
17
+ const fc = (features) => JSON.stringify({ type: "FeatureCollection", features });
18
+
19
+ async function readPoints(P, file) {
20
+ const { rows } = parseCSV(fs.readFileSync(Project.resolveIn(P, file), "utf8"));
21
+ const head = (rows[0] || []).map((h) => String(h).trim());
22
+ return pointsFromRows(head, rows.slice(1)).features.map((f) => ({ lat: f.geometry.coordinates[1], lon: f.geometry.coordinates[0], props: f.properties }));
23
+ }
24
+ function readShapes(P, file) {
25
+ const text = fs.readFileSync(Project.resolveIn(P, file), "utf8");
26
+ if (/\.gpx$/i.test(file)) return fromGPX(text);
27
+ if (/\.kml$/i.test(file)) return fromKML(text);
28
+ const j = JSON.parse(text);
29
+ return j.type === "FeatureCollection" ? j.features : j.type === "Feature" ? [j] : [{ type: "Feature", properties: {}, geometry: j }];
30
+ }
31
+ async function placeOf(x, cfg) {
32
+ if (Array.isArray(x) && x.length === 2) return [Number(x[0]), Number(x[1])];
33
+ const hit = await geocodeOne(String(x), { cfg });
34
+ if (!hit) throw new Error(`não achei "${x}" no mapa`);
35
+ return [hit.lat, hit.lon];
36
+ }
37
+
38
+ export async function runAnalysis(a, { P, cfg, onProgress = () => {} }) {
39
+ if (a.tipo === "mais_proximo") {
40
+ const A = await readPoints(P, a.de), B = await readPoints(P, a.ate);
41
+ if (!A.length || !B.length) throw new Error("as planilhas precisam de pontos com posição");
42
+ const res = nearest(A.map((p) => [p.lat, p.lon]), B.map((p) => [p.lat, p.lon]));
43
+ const arquivo = okFile(a.arquivo, ".csv") ? a.arquivo : `${a.de.replace(/\.csv$/i, "")}-mais-proximo.csv`;
44
+ const cols = Object.keys(A[0].props).filter((c) => !c.startsWith("_"));
45
+ const rows = [[...cols, "latitude", "longitude", "mais_proximo", "distancia_m"],
46
+ ...A.map((p, i) => [...cols.map((c) => p.props[c] ?? ""), String(p.lat), String(p.lon), nameOf(B[res[i].j]?.props, res[i].j), String(Math.round(res[i].metros))])];
47
+ Project.writeSheet(P, arquivo, rows);
48
+ const media = res.reduce((s, r) => s + r.metros, 0) / res.length, far = res.reduce((m, r) => (r.metros > m.metros ? r : m), res[0]);
49
+ return { tipo: a.tipo, arquivo, linhas: A.length, oque: `distância de cada item de ${a.de} até o mais próximo de ${a.ate}: média ${fmtMetros(media)}, maior ${fmtMetros(far.metros)} (${nameOf(A[far.i].props, far.i)} até ${nameOf(B[far.j].props, far.j)})` };
50
+ }
51
+ if (a.tipo === "contar") {
52
+ const pts = await readPoints(P, a.pontos);
53
+ if (a.area) {
54
+ const areas = readShapes(P, a.area).filter((f) => /Polygon/.test(f.geometry?.type || ""));
55
+ const counts = areas.map((f, k) => ({ nome: nameOf(f.properties, k), n: pts.filter((p) => inPolygon([p.lat, p.lon], f.geometry)).length }));
56
+ return { tipo: a.tipo, arquivo: a.area, linhas: counts.length, oque: `pontos de ${a.pontos} dentro de cada área: ${counts.map((c) => `${c.nome}: ${c.n}`).join("; ")}` };
57
+ }
58
+ const centro = await placeOf(a.centro, cfg), metros = Number(a.metros) || 1000;
59
+ const dentro = withinRadius(pts.map((p) => [p.lat, p.lon]), centro, metros);
60
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `contexto/raio-${Math.round(metros)}m.geojson`;
61
+ Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `raio de ${fmtMetros(metros)}`, dentro: dentro.length }, geometry: circle(centro, metros) }]));
62
+ const nomes = dentro.slice(0, 8).map((i) => nameOf(pts[i].props, i)).join(", ");
63
+ return { tipo: a.tipo, arquivo, linhas: dentro.length, oque: `${dentro.length} de ${pts.length} pontos de ${a.pontos} ficam a até ${fmtMetros(metros)}${nomes ? ` (${nomes}${dentro.length > 8 ? "…" : ""})` : ""}; o círculo foi gravado` };
64
+ }
65
+ if (a.tipo === "faixa") {
66
+ const metros = Number(a.metros) || 50;
67
+ const lines = readShapes(P, a.linha).filter((f) => /LineString/.test(f.geometry?.type || ""));
68
+ if (!lines.length) throw new Error(`${a.linha} não tem linhas`);
69
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `${a.linha.replace(/\.[^.]+$/, "")}-faixa-${Math.round(metros)}m.geojson`;
70
+ Project.writeText(P, arquivo, fc(lines.map((f, k) => ({ type: "Feature", properties: { nome: `${nameOf(f.properties, k)} (faixa de ${fmtMetros(metros)})` }, geometry: lineBuffer(f.geometry, metros) }))));
71
+ let extra = "";
72
+ if (a.pontos) {
73
+ const pts = await readPoints(P, a.pontos);
74
+ const perto = pts.filter((p) => lines.some((f) => pointLine([p.lat, p.lon], f.geometry) <= metros));
75
+ extra = `; ${perto.length} de ${pts.length} pontos de ${a.pontos} ficam a até ${fmtMetros(metros)} da linha${perto.length ? ` (${perto.slice(0, 8).map((p, i) => nameOf(p.props, i)).join(", ")}${perto.length > 8 ? "…" : ""})` : ""}`;
76
+ }
77
+ return { tipo: a.tipo, arquivo, linhas: lines.length, oque: `faixa de ${fmtMetros(metros)} em volta de ${a.linha}${extra}` };
78
+ }
79
+ if (a.tipo === "sugerir") {
80
+ const pts = await readPoints(P, a.existentes);
81
+ const area = a.area ? readShapes(P, a.area).find((f) => /Polygon/.test(f.geometry?.type || ""))?.geometry : null;
82
+ const sug = suggestSites(pts.map((p) => [p.lat, p.lon]), { area, quantos: Math.min(Number(a.quantos) || 3, 10) });
83
+ const arquivo = okFile(a.arquivo, ".csv") ? a.arquivo : "contexto/sugestoes.csv";
84
+ Project.writeSheet(P, arquivo, [["nome", "latitude", "longitude", "distancia_m"], ...sug.map((s, k) => [`Sugestão ${k + 1}`, s.lat.toFixed(6), s.lon.toFixed(6), String(Math.round(s.metros ?? 0))])]);
85
+ return { tipo: a.tipo, arquivo, linhas: sug.length, oque: `lugares mais longe dos existentes em ${a.existentes}${a.area ? ` dentro de ${a.area}` : ""}: ${sug.map((s, k) => `Sugestão ${k + 1} a ${fmtMetros(s.metros)} do mais próximo`).join("; ")} (sugestão geométrica: confira acesso, terreno e regras antes)` };
86
+ }
87
+ if (a.tipo === "rota") {
88
+ onProgress("calculando a rota…");
89
+ const de = await placeOf(a.de, cfg), para = await placeOf(a.para, cfg);
90
+ const r = await route([de, para], { modo: a.modo, cfg });
91
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : "contexto/rota.geojson";
92
+ Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `${typeof a.de === "string" ? a.de : "origem"} até ${typeof a.para === "string" ? a.para : "destino"}`, distancia_m: Math.round(r.distancia), minutos: Math.round(r.duracao / 60), modo: MODOS[r.modo] }, geometry: { type: "LineString", coordinates: r.coords } }]));
93
+ return { tipo: a.tipo, arquivo, linhas: 1, oque: `rota ${MODOS[r.modo]}: ${fmtMetros(r.distancia)}, cerca de ${Math.round(r.duracao / 60)} min` };
94
+ }
95
+ if (a.tipo === "alcance") {
96
+ onProgress("calculando a área alcançável…");
97
+ const minutos = Math.min(Math.max(Number(a.minutos) || 10, 1), 60);
98
+ const origens = typeof a.de === "string" && /\.csv$/i.test(a.de)
99
+ ? (await readPoints(P, a.de)).slice(0, 8).map((p, i) => ({ at: [p.lat, p.lon], nome: nameOf(p.props, i) }))
100
+ : [{ at: await placeOf(a.de, cfg), nome: String(a.de) }];
101
+ const feats = [];
102
+ for (const o of origens) feats.push({ type: "Feature", properties: { nome: `${o.nome}: ${minutos} min ${MODOS[a.modo] || "a pé"}` }, geometry: await isochrone(o.at, minutos, { modo: a.modo, cfg }) });
103
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `contexto/alcance-${minutos}min.geojson`;
104
+ Project.writeText(P, arquivo, fc(feats));
105
+ return { tipo: a.tipo, arquivo, linhas: feats.length, oque: `área alcançável em ${minutos} min ${MODOS[a.modo] || "a pé"} a partir de ${origens.length} ${origens.length === 1 ? "ponto" : "pontos"}` };
106
+ }
107
+ if (a.tipo === "encaixar") {
108
+ onProgress("encaixando o rastro nas ruas…");
109
+ const f = readShapes(P, a.trilha).find((x) => /LineString/.test(x.geometry?.type || ""));
110
+ if (!f) throw new Error(`${a.trilha} não tem um rastro (linha)`);
111
+ const coords = f.geometry.type === "LineString" ? f.geometry.coordinates : f.geometry.coordinates.flat();
112
+ const step = Math.max(1, Math.ceil(coords.length / 100)); // o serviço aceita até 100 pontos: amostra o rastro
113
+ const out = await matchTrace(coords.filter((_, i) => i % step === 0).map(([lon, lat]) => [lat, lon]), { modo: a.modo, cfg });
114
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `${a.trilha.replace(/\.[^.]+$/, "")}-nas-ruas.geojson`;
115
+ Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `${nameOf(f.properties, 0)} (nas ruas)` }, geometry: { type: "LineString", coordinates: out } }]));
116
+ return { tipo: a.tipo, arquivo, linhas: 1, oque: `rastro de ${a.trilha} encaixado nas ruas (${out.length} pontos)` };
117
+ }
118
+ throw new Error(`análise desconhecida: ${a.tipo}`);
119
+ }
@@ -0,0 +1,221 @@
1
+ // A IA busca dados geográficos para o mapa (entrega 4), no mesmo molde do "ver o material": a IA que edita o deck,
2
+ // quando precisa de dados de fora, responde um bloco `mapa:` com as ações (lugares do OpenStreetMap, linhas, contornos,
3
+ // uma tabela da web, coordenadas de uma planilha da pessoa). O código executa, grava planilha ou GeoJSON no projeto
4
+ // com a FONTE de cada linha e chama a IA de novo com o que gravou; ela monta o slide. Regras:
5
+ // - a decisão é do modelo (prompt), com o estado de cada serviço à vista; o código recusa, sem pedido de rede, o
6
+ // serviço bloqueado nesta execução (uma tentativa e para);
7
+ // - dados da pessoa (endereços de uma planilha dela) só saem com o sim dela: sem confirmação na conversa, o código
8
+ // não envia e a resposta pergunta (quantos, para qual serviço, quanto tempo);
9
+ // - geometria nunca inventada: vem do OpenStreetMap, da web com fonte, ou dos arquivos.
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { loadMapConfig, mapaFile } from "../map-config.js";
13
+ import { callService, serviceState, geocodeOne, geocodeInterval, ServiceBlocked, SERVICES } from "../map-services.js";
14
+ import * as Project from "../studio/project.js";
15
+ import { addressPlan } from "../studio/geo-routes.js";
16
+ import { chat } from "./llm.js";
17
+ import { jsonOf, runResearch, defaultWeb } from "../research/research.js";
18
+ import { runAnalysis } from "./map-analysis-run.js";
19
+
20
+ // tabela da web: a pesquisa que já existe (busca, lê, anota com a URL) e uma extração que só usa o que leu
21
+ export async function webResearchDefault(pergunta, { saveDir = null } = {}) {
22
+ const r = await runResearch({ pesquisar: true, motivo: pergunta, buscas: [pergunta], academico: false }, { briefing: pergunta, web: defaultWeb, saveDir, maxSources: 5 });
23
+ return r.materials;
24
+ }
25
+ export async function extractTableDefault(pergunta, cols, docs) {
26
+ if (!docs.length) return [];
27
+ const fontes = docs.map((d) => `${d.name}\n${d.detail || ""}\n${clip(d.text, 6000)}`).join("\n\n---\n\n");
28
+ const r = await chat([{ role: "user", content: `Monte a tabela pedida SÓ com o que está nas fontes abaixo. Pedido: ${pergunta}
29
+ Colunas: ${cols.join(", ")}. Cada linha leva também "fonte": a URL da fonte de onde ela saiu (está no detalhe de cada fonte).
30
+ Não invente linhas, nomes nem endereços; o que a fonte não diz fica vazio. Responda só JSON: {"linhas": [{${cols.map((c) => `"${c}": "…"`).join(", ")}, "fonte": "https://…"}]}
31
+
32
+ Fontes:
33
+ ${fontes}` }], { maxTokens: 6000, think: false, allowTruncated: true, retries: 0 });
34
+ const j = jsonOf(r.text) || {};
35
+ return (Array.isArray(j.linhas) ? j.linhas : []).filter((x) => x && typeof x === "object");
36
+ }
37
+
38
+ const clip = (s, n) => (String(s ?? "").length > n ? `${String(s).slice(0, n)}…` : String(s ?? ""));
39
+ const slug = (s) => String(s || "dados").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "dados";
40
+ const okFile = (f, ext) => typeof f === "string" && /^contexto\/[\w\-. /]+$/.test(f) && f.toLowerCase().endsWith(ext) && !f.includes("..");
41
+
42
+ const stateLine = (st) => Object.entries(st).map(([k, v]) => `- ${SERVICES[k]}: ${v.status === "bloqueado" ? `BLOQUEADO nesta rede (${v.motivo}); não peça` : v.status === "ok" ? "funcionando" : "ainda não usado (deve funcionar)"}`).join("\n");
43
+
44
+ // ---- 1. o que a IA principal sabe (vai no prompt de src/ai/deck-ai.js quando há projeto) --------------------------
45
+ // Quem decide é a IA que edita o deck: quando precisa de dados geográficos, ela responde um bloco `mapa:` com as
46
+ // ações (como o `ver:` do material); o Studio executa (runMapData) e chama de novo com o resultado. Sem pedido de
47
+ // mapa, nenhuma chamada a mais.
48
+ export function mapDataPrompt() {
49
+ return `DADOS PARA O MAPA (slide layout: map): quando a pessoa pedir para mostrar no mapa algo que ela NÃO deu (lugares de um
50
+ tipo, linhas como ciclovias ou rios, o contorno de um lugar com nome, uma lista que só existe na web, ou as coordenadas
51
+ dos endereços de uma planilha dela), NÃO invente coordenadas nem geometria: responda uma frase e um bloco yaml só com
52
+ \`\`\`yaml
53
+ mapa:
54
+ - { tipo: lugares, oque: cinemas, filtro_osm: '["amenity"="cinema"]', perto_de: "Centro, Campinas, SP, Brasil", raio_km: 5, arquivo: contexto/cinemas.csv }
55
+ - { tipo: linhas, oque: ciclovias, filtro_osm: '["highway"="cycleway"]', perto_de: "…", raio_km: 3, arquivo: contexto/ciclovias.geojson }
56
+ - { tipo: contorno, nome: "Centro, Campinas, SP, Brasil", arquivo: contexto/centro.geojson }
57
+ - { tipo: web, pergunta: "unidades do INSS em Pernambuco com endereço", colunas: [nome, endereco, cidade], arquivo: contexto/inss-pe.csv }
58
+ - { tipo: coordenadas, planilha: contexto/unidades.csv, confirmado: false }
59
+ \`\`\`
60
+ O Studio busca (OpenStreetMap, web), grava os arquivos com a fonte de cada linha e te devolve o que gravou; aí você monta o
61
+ slide com points:/geojson: e a fonte. filtro_osm são as tags do OpenStreetMap do tipo de lugar. Até 4 ações. Dados que já
62
+ estão nas planilhas do projeto com latitude e longitude não precisam de busca.
63
+ ANÁLISES (contas que o Studio faz sobre os arquivos do projeto; você só narra os números que ele devolver, nunca calcule
64
+ distância, contagem ou tempo de cabeça), no mesmo bloco mapa:
65
+ - { tipo: mais_proximo, de: contexto/pedidos.csv, ate: contexto/lojas.csv } # cada ponto de "de" com o mais próximo de "ate" e a distância
66
+ - { tipo: contar, pontos: contexto/sensores.csv, area: contexto/bairros.geojson } # quantos em cada área (ou centro: [lat, lon] ou "endereço", metros: 500)
67
+ - { tipo: faixa, linha: contexto/rede.geojson, metros: 50, pontos: contexto/imoveis.csv } # faixa em volta da linha e quantos pontos dentro
68
+ - { tipo: sugerir, existentes: contexto/sensores.csv, area: contexto/area.geojson, quantos: 3 } # onde instalar: os lugares mais longe dos existentes
69
+ - { tipo: rota, de: "Rua A, 10, Recife", para: [-8.06, -34.9], modo: pe } # rota pelas ruas com distância e tempo (pe, carro, bicicleta)
70
+ - { tipo: alcance, de: contexto/unidades.csv, minutos: 10, modo: pe } # área alcançável em X minutos (precisa do OpenRouteService)
71
+ - { tipo: encaixar, trilha: contexto/percurso.gpx, modo: carro } # rastro de GPS tremido encaixado nas ruas
72
+ Depois, ponha o resultado no slide (a camada gravada e uma frase com o número) e diga a fonte e o limite da conta.
73
+ Mandar endereços de uma planilha DA PESSOA para um serviço de fora (tipo coordenadas) só com o sim explícito dela nesta
74
+ conversa, dado depois de você dizer quantos endereços, para qual serviço e quanto tempo leva; sem esse sim,
75
+ confirmado: false (o Studio pergunta por você). Dados públicos (lugares, contornos, web) não precisam perguntar.
76
+ Serviços do mapa nesta máquina:
77
+ ${stateLine(serviceState())}
78
+ Nunca peça um serviço BLOQUEADO: diga à pessoa o que não está disponível e ofereça a alternativa (planilha com
79
+ coordenadas, os contornos embutidos de estados e países, a planilha do projeto).`;
80
+ }
81
+
82
+
83
+ // ---- 2. executar -------------------------------------------------------------------------------------------------
84
+ // serviços que cada ação usa (as análises sem rede não usam nenhum; rota com endereço em texto usa o geocoder também)
85
+ const NEEDS = { lugares: ["geocoder", "busca"], linhas: ["geocoder", "busca"], contorno: ["geocoder"], web: ["geocoder"], coordenadas: ["geocoder"],
86
+ mais_proximo: [], contar: [], faixa: [], sugerir: [], rota: ["rotas"], alcance: ["rotas"], encaixar: ["rotas"] };
87
+ const ANALYSES = new Set(["mais_proximo", "contar", "faixa", "sugerir", "rota", "alcance", "encaixar"]); // src/ai/map-analysis-run.js
88
+
89
+ async function centerOf(where, cfg) {
90
+ const hit = await geocodeOne(where, { cfg });
91
+ if (!hit) throw new Error(`não achei "${where}" no mapa`);
92
+ return hit;
93
+ }
94
+
95
+ async function overpass(query, cfg) {
96
+ const res = await callService("busca", cfg.busca.url, { method: "POST", cfg, headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `data=${encodeURIComponent(query)}`, timeoutMs: 40000 });
97
+ return (await res.json()).elements || [];
98
+ }
99
+
100
+ const tagAddress = (t) => [t["addr:street"] && `${t["addr:street"]}${t["addr:housenumber"] ? `, ${t["addr:housenumber"]}` : ""}`, t["addr:suburb"], t["addr:city"]].filter(Boolean).join(" - ");
101
+
102
+ export async function runMapData(acoes, { P, cfg = loadMapConfig(mapaFile()), onProgress = () => {}, webResearch = (q) => webResearchDefault(q, { saveDir: P?.dir }), extractTable = extractTableDefault } = {}) {
103
+ const feitos = [], perguntas = [], falhas = [];
104
+ Project.ensureMeta(P); // contexto/ existe antes de gravar (writeSheet não cria a pasta)
105
+ for (const a of acoes) {
106
+ if (!NEEDS[a.tipo]) { falhas.push(`${a.tipo}: ação desconhecida`); continue; }
107
+ const blocked = NEEDS[a.tipo].map((s) => serviceState()[s]).find((s) => s.status === "bloqueado");
108
+ if (blocked) { falhas.push(`${a.tipo}${a.oque || a.nome ? ` (${a.oque || a.nome})` : ""}: serviço bloqueado nesta rede (${blocked.motivo}); não tentei`); continue; }
109
+ try {
110
+ if (a.tipo === "lugares" || a.tipo === "linhas") {
111
+ const arquivo = okFile(a.arquivo, a.tipo === "lugares" ? ".csv" : ".geojson") ? a.arquivo : `contexto/${slug(a.oque)}.${a.tipo === "lugares" ? "csv" : "geojson"}`;
112
+ const filtro = String(a.filtro_osm || "").replace(/[;(){}]/g, "");
113
+ if (!/^(\[[^\]]+\])+$/.test(filtro)) throw new Error(`filtro do OpenStreetMap inválido: ${filtro}`);
114
+ onProgress(`procurando ${a.oque || a.tipo} perto de ${a.perto_de}…`);
115
+ const c = await centerOf(a.perto_de, cfg);
116
+ const r = Math.round(Math.min(Math.max(Number(a.raio_km) || 3, 0.2), 30) * 1000);
117
+ if (a.tipo === "lugares") {
118
+ const els = await overpass(`[out:json][timeout:30];(node${filtro}(around:${r},${c.lat},${c.lon});way${filtro}(around:${r},${c.lat},${c.lon}););out center tags 300;`, cfg);
119
+ const rows = [["nome", "endereco", "latitude", "longitude", "fonte"]];
120
+ for (const e of els) {
121
+ const lat = e.lat ?? e.center?.lat, lon = e.lon ?? e.center?.lon;
122
+ if (lat == null || lon == null) continue;
123
+ rows.push([e.tags?.name || "(sem nome)", tagAddress(e.tags || {}), String(lat), String(lon), `https://www.openstreetmap.org/${e.type}/${e.id}`]);
124
+ }
125
+ Project.writeSheet(P, arquivo, rows);
126
+ feitos.push({ tipo: a.tipo, arquivo, linhas: rows.length - 1, oque: a.oque, perto_de: a.perto_de, fonte: "OpenStreetMap (© colaboradores do OpenStreetMap)" });
127
+ } else {
128
+ const els = await overpass(`[out:json][timeout:30];way${filtro}(around:${r},${c.lat},${c.lon});out geom tags 300;`, cfg);
129
+ const features = els.filter((e) => Array.isArray(e.geometry) && e.geometry.length > 1).map((e) => ({ type: "Feature", properties: { nome: e.tags?.name || "(sem nome)", fonte: `https://www.openstreetmap.org/way/${e.id}` }, geometry: { type: "LineString", coordinates: e.geometry.map((g) => [g.lon, g.lat]) } }));
130
+ Project.writeText(P, arquivo, JSON.stringify({ type: "FeatureCollection", features }));
131
+ feitos.push({ tipo: a.tipo, arquivo, linhas: features.length, oque: a.oque, perto_de: a.perto_de, fonte: "OpenStreetMap (© colaboradores do OpenStreetMap)" });
132
+ }
133
+ } else if (a.tipo === "contorno") {
134
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `contexto/${slug(a.nome)}.geojson`;
135
+ onProgress(`procurando o contorno de ${a.nome}…`);
136
+ const g = cfg.geocoder;
137
+ const res = await callService("geocoder", `${g.url.replace(/\/+$/, "")}/search?format=jsonv2&limit=1&polygon_geojson=1&polygon_threshold=0.0005&q=${encodeURIComponent(a.nome)}`, { cfg });
138
+ const hit = (await res.json())?.[0];
139
+ if (!hit?.geojson || !/Polygon/.test(hit.geojson.type)) throw new Error(`não achei o contorno de "${a.nome}"`);
140
+ Project.writeText(P, arquivo, JSON.stringify({ type: "FeatureCollection", features: [{ type: "Feature", properties: { nome: a.nome, fonte: `https://www.openstreetmap.org/${hit.osm_type}/${hit.osm_id}` }, geometry: hit.geojson }] }));
141
+ feitos.push({ tipo: a.tipo, arquivo, linhas: 1, oque: a.nome, fonte: "OpenStreetMap (© colaboradores do OpenStreetMap)" });
142
+ } else if (a.tipo === "web") {
143
+ if (!webResearch || !extractTable) throw new Error("pesquisa na web indisponível");
144
+ const arquivo = okFile(a.arquivo, ".csv") ? a.arquivo : `contexto/${slug(a.pergunta)}.csv`;
145
+ onProgress(`pesquisando na web: ${a.pergunta}…`);
146
+ const docs = await webResearch(a.pergunta);
147
+ const cols = (Array.isArray(a.colunas) && a.colunas.length ? a.colunas : ["nome", "endereco"]).map(String).slice(0, 8);
148
+ const table = await extractTable(a.pergunta, cols, docs); // [{…colunas, fonte}]
149
+ const rows = [[...cols, "latitude", "longitude", "fonte"]];
150
+ const wait = geocodeInterval(cfg);
151
+ let last = 0;
152
+ for (const t of table.slice(0, 200)) {
153
+ const q = [t.endereco, t.cidade, t.uf].filter(Boolean).join(", ") || t.nome;
154
+ let hit = null;
155
+ if (q) { const pause = last + wait - Date.now(); if (pause > 0) await new Promise((r) => setTimeout(r, pause)); last = Date.now(); hit = await geocodeOne(q, { cfg }).catch((e) => { if (e instanceof ServiceBlocked) throw e; return null; }); }
156
+ rows.push([...cols.map((c) => String(t[c] ?? "")), hit ? String(hit.lat) : "", hit ? String(hit.lon) : "", String(t.fonte || "")]);
157
+ }
158
+ Project.writeSheet(P, arquivo, rows);
159
+ feitos.push({ tipo: a.tipo, arquivo, linhas: rows.length - 1, oque: a.pergunta, fonte: "web (a coluna fonte de cada linha diz de onde veio; revise antes de apresentar)" });
160
+ } else if (ANALYSES.has(a.tipo)) {
161
+ feitos.push(await runAnalysis(a, { P, cfg, onProgress }));
162
+ } else if (a.tipo === "coordenadas") {
163
+ const abs = Project.resolveIn(P, a.planilha);
164
+ const sheet = (await Project.readSheet(abs)).sheets[0];
165
+ const pl = addressPlan(sheet.rows, a.coluna);
166
+ if (pl.error) throw new Error(pl.error);
167
+ if (!pl.items.length) { feitos.push({ tipo: a.tipo, arquivo: a.planilha, linhas: 0, oque: "todas já tinham coordenadas" }); continue; }
168
+ if (a.confirmado !== true) {
169
+ const host = (() => { try { return new URL(cfg.geocoder.url).host; } catch { return cfg.geocoder.url; } })();
170
+ perguntas.push(`Para pôr no mapa, preciso achar as coordenadas de ${pl.items.length} endereço${pl.items.length === 1 ? "" : "s"} da planilha ${a.planilha} (coluna ${pl.column}). Isso envia esses endereços ao serviço ${host} e leva cerca de ${Math.max(1, Math.ceil((pl.items.length * geocodeInterval(cfg)) / 60000))} min. Posso enviar? (Também dá para fazer pela planilha, no botão Achar coordenadas.)`);
171
+ continue;
172
+ }
173
+ onProgress(`achando as coordenadas de ${pl.items.length} endereços…`);
174
+ const rows = sheet.rows.map((r) => [...r]);
175
+ let { iLat, iLon } = pl;
176
+ if (iLat < 0 || iLon < 0) { rows[0].push("latitude", "longitude"); iLat = rows[0].length - 2; iLon = rows[0].length - 1; }
177
+ const wait = geocodeInterval(cfg);
178
+ let ok = 0, last = 0;
179
+ for (const it of pl.items) {
180
+ const pause = last + wait - Date.now(); if (pause > 0) await new Promise((r) => setTimeout(r, pause)); last = Date.now();
181
+ const hit = await geocodeOne(it.q, { cfg });
182
+ if (hit) { ok++; const r = rows[it.row]; while (r.length < rows[0].length) r.push(""); r[iLat] = String(hit.lat); r[iLon] = String(hit.lon); }
183
+ }
184
+ const out = /\.(csv|tsv)$/i.test(a.planilha) ? a.planilha : a.planilha.replace(/\.[^.]+$/, "") + ".csv";
185
+ Project.writeSheet(P, out, rows);
186
+ feitos.push({ tipo: a.tipo, arquivo: out, linhas: ok, oque: `coordenadas de ${ok} de ${pl.items.length} endereços` });
187
+ }
188
+ } catch (e) {
189
+ falhas.push(`${a.tipo}${a.oque || a.nome ? ` (${a.oque || a.nome})` : ""}: ${e.message}`);
190
+ if (e instanceof ServiceBlocked) continue;
191
+ }
192
+ }
193
+ return { feitos, perguntas, falhas };
194
+ }
195
+
196
+ // o que a IA recebe junto do pedido: o que foi gravado (para usar no slide) e o que falhou (para dizer à pessoa)
197
+ export function mapDataInstruction({ feitos, falhas }) {
198
+ if (!feitos.length && !falhas.length) return "";
199
+ const lines = feitos.map((f) => `- ${f.arquivo}: ${f.linhas} ${f.tipo === "contorno" ? "contorno" : f.tipo === "linhas" ? "linhas" : "linhas"} (${f.oque}${f.perto_de ? `, perto de ${f.perto_de}` : ""}); fonte: ${f.fonte || "planilha da pessoa"}`);
200
+ const vazios = feitos.filter((f) => (f.tipo === "lugares" || f.tipo === "linhas") && !f.linhas);
201
+ return `[Dados geográficos buscados pelo sagadeck agora]\n${lines.join("\n")}${vazios.length ? `\nA busca não achou nada em ${vazios.map((f) => f.oque).join(", ")} nesse raio: diga isso e ofereça buscar num raio maior (ou outro lugar), sem montar mapa vazio.` : ""}${falhas.length ? `\nNão deu: ${falhas.join("; ")}. Diga isso à pessoa sem inventar o que faltou.` : ""}
202
+ Use esses arquivos no slide de mapa (points: para .csv, geojson: para .geojson), com a fonte numa nota ou em source: ("Fonte: OpenStreetMap"). Dado da web: avise que vale revisar.`;
203
+ }
204
+
205
+ // planilhas do projeto, para a decisão saber o que já existe
206
+ export async function projectSheets(P) {
207
+ const out = [];
208
+ const dir = path.join(P.dir, "contexto");
209
+ const walk = (d) => { for (const e of fs.existsSync(d) ? fs.readdirSync(d, { withFileTypes: true }) : []) { const f = path.join(d, e.name); if (e.isDirectory()) walk(f); else if (/\.(csv|tsv|xlsx)$/i.test(e.name)) out.push(f); } };
210
+ walk(dir);
211
+ const sheets = [];
212
+ for (const f of out.slice(0, 12)) {
213
+ try {
214
+ const rows = (await Project.readSheet(f)).sheets[0].rows;
215
+ const rel = path.relative(P.dir, f).split(path.sep).join("/");
216
+ const pl = addressPlan(rows);
217
+ sheets.push({ path: rel, columns: (rows[0] || []).map(String).slice(0, 12), rows: Math.max(0, rows.length - 1), semPosicao: pl.error ? 0 : pl.items.length });
218
+ } catch { /* planilha que não abre: fica de fora */ }
219
+ }
220
+ return sheets;
221
+ }
package/src/ai/variety.js CHANGED
@@ -38,7 +38,7 @@ export function varietyReport(spec) {
38
38
  // Direções criativas: cada deck gerado sorteia um jeito de contar (e de se parecer), para dois decks sobre
39
39
  // assuntos parecidos não saírem iguais.
40
40
  export const CREATIVE_DIRECTIONS = [
41
- "Keynote minimalista: uma frase de abertura sem ilustração, grandes espaços vazios, composição central, um número por ideia. Considere prata; destaque em cor apenas na virada.",
41
+ "Palco minimalista: uma frase de abertura sem ilustração, grandes espaços vazios, composição central, um número por ideia. Considere prata; destaque em cor apenas na virada.",
42
42
  "Investigação editorial: abra com uma cena concreta, revele pistas por enquadramentos e comparações, entregue a conclusão no final. Considere editorial; serifas, linhas finas, papel claro e um único contraste escuro.",
43
43
  "Workshop geométrico: abra com uma escolha da plateia, alterne desafios curtos, diagramas e timer. Considere bauhaus ou pop; formas primárias, assimetria e revelação por clique.",
44
44
  "Estudo de caso documental: contexto → problema → tentativa → resultado → lição. Considere jornal; manchetes, evidências visuais, cronologia e legendas curtas. Só use dados do briefing ou dados verificados.",
package/src/build.js CHANGED
@@ -20,6 +20,7 @@ import { normalizeSpec } from "./fiscal/normalize.js";
20
20
  import { readRecordings } from "./api-client.js";
21
21
  import { explorationStates } from './exploration.js';
22
22
  import { calcModel } from './lessons.js';
23
+ import { loadMapConfig, mapaFile, tilesForDeck } from "./map-config.js";
23
24
 
24
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
25
26
  const read = (p) => fs.readFileSync(path.join(HERE, p), "utf8");
@@ -132,7 +133,9 @@ export function buildHTML(rawSpec, opts = {}) {
132
133
  const theme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
133
134
  const warnings = [];
134
135
  if (spec.identity && !theme.identity) warnings.push(`identidade "${spec.identity}" não está em ${identitiesFile()} nesta máquina: a apresentação saiu com as fontes do tema`);
135
- const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings };
136
+ // serviços do mapa desta máquina (só o fundo vai para o HTML; nunca chave de rotas/endereços): src/map-config.js
137
+ const mapTiles = (spec.slides || []).some((x) => x?.layout === "map") ? tilesForDeck(loadMapConfig(mapaFile())) : null;
138
+ const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings, mapTiles };
136
139
  const id = spec.id || slug(spec.title);
137
140
  const slidesMeta = [];
138
141
  let html = "";
@@ -231,6 +234,7 @@ ${spec.slides.some((s) => s.layout === "diagram") ? `<script>${read("runtime/ven
231
234
  ${spec.slides.some(s => s.layout === "science") ? `<script>${read("runtime/vendor/plotly.min.js").replace(/<\/script/gi,"<\\/script")}</script><script>${read("runtime/formula.js")}</script><script>${read("runtime/science.js")}</script>` : ""}
232
235
  ${spec.slides.some(s => s.layout === "calc") ? `${spec.slides.some(s => s.layout === "science") ? "" : `<script>${read("runtime/formula.js")}</script>`}<script>${read("runtime/calc.js")}</script>` : ""}
233
236
  ${spec.slides.some(s=>s.layout==="portal") ? `<script>${read("runtime/portal-scene.js")}</script>` : ""}
237
+ ${spec.slides.some((s) => s.layout === "map") ? `<style>${read("runtime/vendor/leaflet.css")}</style><script>${read("runtime/vendor/leaflet.js").replace(/<\/script/gi, "<\\/script")}</script><script>${read("runtime/map.js")}</script>` : ""}
234
238
  ${JSON.stringify(spec.slides || []).includes('"points"') ? `<script>${read("runtime/points.js")}</script>` : ""}
235
239
  ${spec.slides.some(s=>s.layout==="playground") ? `<script>${read("runtime/playground.js")}</script>` : ""}
236
240
  ${hasApi ? `${apiScripts}\n<script>${read("runtime/api-ui.js")}</script><script>${read("runtime/api-collection-ui.js")}</script>` : ""}
@@ -313,7 +317,7 @@ export function renderSlide(raw, i = 0, spec = {}) {
313
317
  const deckTheme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
314
318
  const s = { ...(spec.defaults || {}), ...raw };
315
319
  const theme = slideTheme(s, deckTheme, spec);
316
- const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings: [] };
320
+ const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings: [], mapTiles: inferLayout(s) === "map" ? tilesForDeck(loadMapConfig(mapaFile())) : null };
317
321
  const layout = inferLayout(s);
318
322
  const fn = LAYOUTS[layout];
319
323
  if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe.`);
@@ -4,7 +4,7 @@
4
4
  export const EXPERIENCES = [
5
5
  { id: "executivo", name: "Sala de decisão", subtitle: "Clareza que convence.", description: "Uma recomendação, evidências e a decisão. Elegância sóbria para reuniões importantes.", theme: "noite", accent: "#E4B660", background: "#0F1115", color: "#EDEBE6", font: "Segoe UI", category: "executive", tags: ["Estratégia", "Dados", "Sóbrio"], slideCount: 8 },
6
6
  { id: "editorial", name: "Matéria de capa", subtitle: "Uma história bem contada.", description: "Tipografia editorial, enquadramentos amplos e pistas que se revelam até a virada.", theme: "editorial", accent: "#E0432B", background: "#FAF9F6", color: "#141414", font: "Georgia", category: "creative", tags: ["Storytelling", "Editorial", "Autoral"], slideCount: 8 },
7
- { id: "palco", name: "Modo espetáculo", subtitle: "A próxima ideia já vem.", description: "Contraste, pausas dramáticas e participação da plateia. Feito para ocupar o palco.", theme: "aurora", accent: "#00F2FE", background: "#0A0D17", color: "#F8FAFC", font: "Segoe UI", category: "stage", tags: ["Keynote", "Impacto", "Interativo"], slideCount: 8 },
7
+ { id: "palco", name: "Modo espetáculo", subtitle: "A próxima ideia já vem.", description: "Contraste, pausas dramáticas e participação da plateia. Feito para ocupar o palco.", theme: "aurora", accent: "#00F2FE", background: "#0A0D17", color: "#F8FAFC", font: "Segoe UI", category: "stage", tags: ["Palco", "Impacto", "Interativo"], slideCount: 8 },
8
8
  { id: "pop", name: "Clube criativo", subtitle: "Ideias que saem da linha.", description: "Formas geométricas, cores primárias e um workshop com espaço para experimentar.", theme: "bauhaus", accent: "#D93A2B", background: "#F3EEE3", color: "#1A1A1A", font: "Century Gothic", category: "creative", tags: ["Workshop", "Geométrico", "Divertido"], slideCount: 8 },
9
9
  { id: "aula", name: "Laboratório vivo", subtitle: "Entender. Prever. Experimentar.", description: "Código por etapas, terminal, screenshot com foco e desafios para ensinar programação.", theme: "terminal", accent: "#FFB224", background: "#0D0E0D", color: "#DAD6CC", font: "Cascadia Mono", category: "code", tags: ["Programação", "Código", "Aulas"], slideCount: 9 },
10
10
  { id: "minimal", name: "Uma ideia por vez", subtitle: "Só o que importa.", description: "Uma ideia por vez. Muito espaço, tipografia precisa e detalhes que deixam o conteúdo respirar.", theme: "prata", accent: "#0071E3", background: "#F5F5F7", color: "#1D1D1F", font: "Segoe UI", category: "executive", tags: ["Clean", "Produto", "Respiro"], slideCount: 8 },
@@ -1,5 +1,7 @@
1
1
  // Abre o deck num Chrome/Edge headless (usa o navegador já instalado; nada é baixado).
2
2
  import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
3
5
  import { pathToFileURL } from "node:url";
4
6
  import { chromium } from "playwright-core";
5
7
 
@@ -20,14 +22,34 @@ export function findBrowser() {
20
22
  return p;
21
23
  }
22
24
 
25
+ // Deck com mapa abre por um endereço local (http://127.0.0.1), não como arquivo: o servidor de tiles do OpenStreetMap
26
+ // pede que o navegador diga de onde vem o pedido, e página aberta do disco não diz. Só lê a pasta do deck.
27
+ async function serveDir(dir) {
28
+ const server = http.createServer((req, res) => {
29
+ const rel = decodeURIComponent(new URL(req.url, "http://x").pathname).replace(/^\/+/, "");
30
+ const file = path.resolve(dir, rel);
31
+ if (req.method !== "GET" || !file.startsWith(path.resolve(dir)) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); return res.end(); }
32
+ res.writeHead(200, { "Content-Type": file.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream" });
33
+ fs.createReadStream(file).pipe(res);
34
+ });
35
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
36
+ return server;
37
+ }
38
+
39
+ // a exportação, depois de ir a um slide, espera o mapa dele (tiles carregados ou desistência; no máximo 8 s)
40
+ export const settleSlide = (page) => page.evaluate(() => window.SagaMap?.settled?.() ?? null);
41
+
23
42
  export async function openDeck(htmlFile, { scale = 1 } = {}) {
24
43
  const browser = await chromium.launch({ executablePath: findBrowser() });
44
+ const hasMap = fs.readFileSync(htmlFile, "utf8").includes('class="map-box');
45
+ const server = hasMap ? await serveDir(path.dirname(htmlFile)) : null;
46
+ if (server) { const close = browser.close.bind(browser); browser.close = async () => { await close(); server.close(); }; }
25
47
  try {
26
48
  const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: scale });
27
49
  const errors = [];
28
50
  page.on("pageerror", (e) => errors.push(e.message));
29
51
  page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
30
- await page.goto(pathToFileURL(htmlFile).href + "?export=1");
52
+ await page.goto((server ? `http://127.0.0.1:${server.address().port}/${encodeURIComponent(path.basename(htmlFile))}` : pathToFileURL(htmlFile).href) + "?export=1");
31
53
  await page.waitForFunction(() => window.sagadeck && window.sagadeck.cur >= 0 && document.fonts.status === "loaded");
32
54
  // a janela com o tamanho do slide (deck.aspect: 4:3 → 1920 × 1440): foto, PDF e PPTX sem faixa nem corte
33
55
  const size = await page.evaluate(() => window.sagadeck.size || { w: 1920, h: 1080 });
@@ -9,7 +9,7 @@
9
9
  import fs from "node:fs";
10
10
  import PptxGenJS from "pptxgenjs";
11
11
  import JSZip from "jszip";
12
- import { openDeck } from "./browser.js";
12
+ import { openDeck, settleSlide } from "./browser.js";
13
13
  import { pptxFontMap, macFallbackFace } from "../themes.js";
14
14
  import { notesPlain } from "../markup.js";
15
15
 
@@ -353,6 +353,7 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
353
353
  for (let i = 0; i < n; i++) {
354
354
  const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
355
355
  await page.evaluate(([j, k]) => window.sagadeck.goto(j, k, true), [i, S]);
356
+ await settleSlide(page);
356
357
  await page.waitForTimeout(40);
357
358
  const data = await page.evaluate(collectInPage, [i, nativeCharts]);
358
359
  const slide = pres.addSlide();
@@ -1,7 +1,7 @@
1
1
  // PNG de cada slide, folha de contato, PDF e verificação automática de qualidade.
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { openDeck, waitForResources } from "./browser.js";
4
+ import { openDeck, waitForResources, settleSlide } from "./browser.js";
5
5
 
6
6
  // Estado final de cada slide (todos os cliques revelados)
7
7
  export async function shots(htmlFile, outDir, { steps = false, scale = 1, only, jpeg = false } = {}) {
@@ -17,6 +17,7 @@ export async function shots(htmlFile, outDir, { steps = false, scale = 1, only,
17
17
  const list = steps ? Array.from({ length: S + 1 }, (_, k) => k) : [S];
18
18
  for (const k of list) {
19
19
  await page.evaluate(([j, kk]) => window.sagadeck.goto(j, kk), [i, k]);
20
+ await settleSlide(page);
20
21
  await page.waitForTimeout(60);
21
22
  const f = path.join(outDir, `slide-${String(i + 1).padStart(2, "0")}${steps ? `-${k}` : ""}.${jpeg ? "jpg" : "png"}`);
22
23
  await page.screenshot(jpeg ? { path: f, type: "jpeg", quality: 82 } : { path: f });
@@ -56,6 +57,7 @@ export async function pdf(htmlFile, outFile) {
56
57
  for (let i = 0; i < n; i++) {
57
58
  const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
58
59
  await page.evaluate(([j, k]) => window.sagadeck.goto(j, k), [i, S]);
60
+ await settleSlide(page);
59
61
  await page.waitForTimeout(50);
60
62
  imgs.push((await page.screenshot({ type: "jpeg", quality: 92 })).toString("base64"));
61
63
  links.push(await page.evaluate(gotoRects, i));
package/src/layouts.js CHANGED
@@ -22,6 +22,7 @@ import { slideSize } from "./aspect.js";
22
22
  import { recreatedFrame } from "./master.js";
23
23
  import { solutionHTML, calcHTML, algoHTML } from "./lessons.js";
24
24
  import "./runtime/api-core.js"; // globalThis.SagadeckApiCore (o mesmo núcleo que roda na apresentação)
25
+ import { mapModel, mapPreviewSVG, mapSummary } from "./map.js";
25
26
 
26
27
  const plainTitle = (s) => String(s.title || s.kicker || s.layout || "").replace(/[*=^~`]/g, "").slice(0, 40);
27
28
  const kicker = (s, d = 0) => (s.kicker ? `<div class="kicker t f-label e" style="--d:${d}">${md(s.kicker)}</div>` : "");
@@ -733,6 +734,15 @@ export const LAYOUTS = {
733
734
  calc(s, ctx) { return `${calcHTML(s, ctx, head)}${src(s)}${add(s, ctx)}`; },
734
735
  algo(s, ctx) { return `${algoHTML(s, ctx, head)}${src(s)}${add(s, ctx)}`; },
735
736
 
737
+ // Mapa: camadas de planilha, GeoJSON, GPX, KML ou valores por estado/país (src/map.js), desenhadas ao vivo pelo
738
+ // Leaflet só quando o slide aparece (src/runtime/map.js). A prévia em SVG fica por baixo (miniaturas, reserva).
739
+ // Cada camada com passo ganha um marcador invisível data-step: o clique que a revela é o mesmo dos outros slides.
740
+ map(s, ctx) {
741
+ const m = mapModel(s, ctx);
742
+ const steps = m.layers.map((l, i) => (l.step ? `<i class="map-step" data-step="${l.step}" data-layer="${i}" hidden></i>` : "")).join("");
743
+ const data = JSON.stringify(m).replace(/</g, "\\u003c");
744
+ return `<div class="L-map">${head(s)}<div class="map-box e" style="--d:2" data-basemap="${m.basemap}" aria-label="Mapa: ${esc(mapSummary(m))}">${mapPreviewSVG(m)}<div class="map-live"></div><script type="application/json" class="map-data">${data}</script>${steps}</div>${s.caption ? text(s.caption, "small", { class: "map-caption muted" }) : ""}</div>${src(s)}${add(s, ctx)}`;
745
+ },
736
746
  science(s, ctx) {
737
747
  return `<div class="L-science">${head(s)}<div class="science-body${s.plot === false ? ' equations-only' : !(s.equations || []).length ? ' plot-only' : ''}"><div class="science-equations">${mathHTML(s.equations || [])}</div>${s.plot === false ? '' : plotHTML(s.plot || {}, ctx)}</div>${s.caption ? text(s.caption,'small') : ''}</div>${add(s,ctx)}`;
738
748
  },
package/src/library.js CHANGED
@@ -429,10 +429,10 @@ export function openLibrary(root, { trashDays } = {}) {
429
429
  return idOf(file);
430
430
  }
431
431
 
432
- // .pptx (PowerPoint, Google Slides, Keynote exportado) vira uma apresentação da biblioteca, fiel ao original
432
+ // .pptx (PowerPoint, Google Slides e outros programas que exportam .pptx) vira uma apresentação da biblioteca, fiel ao original
433
433
  // (src/import): cada slide na mesma posição, mídias em imagens/original/, cópia e fotos do original em original/
434
434
  async function importOffice(buffer, topic, name = "Importada.pptx", opts = {}) {
435
- if (!/\.pptx$/i.test(name)) throw new Error("Por enquanto a importação lê .pptx (o PowerPoint salva em .pptx; o Google Slides e o Keynote exportam).");
435
+ if (!/\.pptx$/i.test(name)) throw new Error("Por enquanto a importação lê .pptx (o PowerPoint salva em .pptx; o Google Slides e outros programas exportam).");
436
436
  const { importToDir } = await import("./import/index.js");
437
437
  const base = topicDir(topic || ensureTopic("Importados"));
438
438
  const dir = uniquePath(base, safeName(opts.title || name.replace(/\.pptx$/i, "")));