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.
- package/README.md +41 -1
- package/SKILL.md +3 -3
- package/bin/sagadeck.js +2 -2
- package/docs/ANDAMENTO.md +162 -4
- package/docs/EXPERIENCIAS.md +1 -1
- package/docs/REFERENCIA.md +59 -1
- package/package.json +2 -1
- package/src/ai/deck-ai.js +13 -4
- package/src/ai/ia-config.js +17 -2
- package/src/ai/llm.js +6 -4
- package/src/ai/map-analysis-run.js +119 -0
- package/src/ai/map-data.js +221 -0
- package/src/ai/variety.js +1 -1
- package/src/build.js +6 -2
- package/src/experiences.js +1 -1
- package/src/export/browser.js +23 -1
- package/src/export/pptx.js +2 -1
- package/src/export/shots.js +3 -1
- package/src/layouts.js +10 -0
- package/src/library.js +2 -2
- package/src/map-analysis.js +106 -0
- package/src/map-config.js +83 -0
- package/src/map-routing.js +82 -0
- package/src/map-services.js +60 -0
- package/src/map.js +261 -0
- package/src/mcp/server.js +4 -4
- package/src/runtime/base.css +21 -1
- package/src/runtime/code-lab.js +10 -10
- package/src/runtime/map.js +234 -0
- package/src/runtime/runtime.js +1 -1
- package/src/runtime/skins/aluminio.css +1 -1
- package/src/runtime/skins/prata.css +1 -1
- package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
- package/src/runtime/vendor/geo-contornos.json +1 -0
- package/src/runtime/vendor/leaflet.css +661 -0
- package/src/runtime/vendor/leaflet.js +6 -0
- package/src/studio/ai-chat-route.js +20 -0
- package/src/studio/banner.js +1 -1
- package/src/studio/demo-decks.js +3 -1
- package/src/studio/geo-routes.js +138 -0
- package/src/studio/ia-routes.js +4 -1
- package/src/studio/layout-samples.js +71 -0
- package/src/studio/project.js +1 -1
- package/src/studio/public/ai-settings.js +15 -2
- package/src/studio/public/app.js +13 -7
- package/src/studio/public/chat.js +1 -1
- package/src/studio/public/explorer.js +23 -8
- package/src/studio/public/index.html +4 -0
- package/src/studio/public/library.css +2 -1
- package/src/studio/public/library.html +2 -0
- package/src/studio/public/library.js +10 -1
- package/src/studio/public/map-data.js +154 -0
- package/src/studio/public/map-editor.js +287 -0
- package/src/studio/public/map-fields.js +21 -0
- package/src/studio/public/map-settings.js +84 -0
- package/src/studio/public/slide-form.js +1 -1
- package/src/studio/public/style.css +13 -0
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/public/viewers.js +2 -1
- package/src/studio/public/visual-editor.js +1 -1
- package/src/studio/server.js +13 -1
- package/src/studio/tour.js +76 -0
- package/src/templates/scaffold.js +5 -5
- package/src/themes.js +5 -5
- 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
|
-
"
|
|
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
|
-
|
|
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.`);
|
package/src/experiences.js
CHANGED
|
@@ -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: ["
|
|
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 },
|
package/src/export/browser.js
CHANGED
|
@@ -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 });
|
package/src/export/pptx.js
CHANGED
|
@@ -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();
|
package/src/export/shots.js
CHANGED
|
@@ -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
|
|
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
|
|
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, "")));
|