sagadeck 1.7.1 → 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 (46) hide show
  1. package/README.md +40 -0
  2. package/docs/ANDAMENTO.md +122 -0
  3. package/docs/REFERENCIA.md +58 -0
  4. package/package.json +2 -1
  5. package/src/ai/deck-ai.js +13 -4
  6. package/src/ai/map-analysis-run.js +119 -0
  7. package/src/ai/map-data.js +221 -0
  8. package/src/build.js +6 -2
  9. package/src/export/browser.js +23 -1
  10. package/src/export/pptx.js +2 -1
  11. package/src/export/shots.js +3 -1
  12. package/src/layouts.js +10 -0
  13. package/src/map-analysis.js +106 -0
  14. package/src/map-config.js +83 -0
  15. package/src/map-routing.js +82 -0
  16. package/src/map-services.js +60 -0
  17. package/src/map.js +261 -0
  18. package/src/runtime/base.css +20 -0
  19. package/src/runtime/map.js +234 -0
  20. package/src/runtime/runtime.js +1 -1
  21. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  22. package/src/runtime/vendor/geo-contornos.json +1 -0
  23. package/src/runtime/vendor/leaflet.css +661 -0
  24. package/src/runtime/vendor/leaflet.js +6 -0
  25. package/src/studio/ai-chat-route.js +20 -0
  26. package/src/studio/demo-decks.js +3 -1
  27. package/src/studio/geo-routes.js +138 -0
  28. package/src/studio/layout-samples.js +71 -0
  29. package/src/studio/project.js +1 -1
  30. package/src/studio/public/ai-settings.js +4 -1
  31. package/src/studio/public/app.js +7 -3
  32. package/src/studio/public/explorer.js +23 -8
  33. package/src/studio/public/index.html +4 -0
  34. package/src/studio/public/library.html +2 -0
  35. package/src/studio/public/library.js +6 -1
  36. package/src/studio/public/map-data.js +154 -0
  37. package/src/studio/public/map-editor.js +287 -0
  38. package/src/studio/public/map-fields.js +21 -0
  39. package/src/studio/public/map-settings.js +84 -0
  40. package/src/studio/public/slide-form.js +1 -1
  41. package/src/studio/public/style.css +13 -0
  42. package/src/studio/public/ui-icons.js +1 -1
  43. package/src/studio/public/viewers.js +2 -1
  44. package/src/studio/public/visual-editor.js +1 -1
  45. package/src/studio/server.js +10 -0
  46. package/src/studio/tour.js +76 -0
@@ -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/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.`);
@@ -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
  },
@@ -0,0 +1,106 @@
1
+ // Análises do mapa (entrega 5): contas que o CÓDIGO faz e a IA só narra. Sem rede (as que precisam de serviço,
2
+ // como rota com tempo e área alcançável, ficam em src/map-routing.js). Coordenadas em [lat, lon]; GeoJSON em
3
+ // [lon, lat]. Distâncias na esfera (haversine), boas para cidade e região.
4
+ import { distance } from "./map-routing.js";
5
+
6
+ const R = 6371008.8, rad = Math.PI / 180;
7
+
8
+ // ponto mais próximo de cada ponto de A, entre os pontos de B: [{ i, j, metros }]
9
+ export function nearest(A, B) {
10
+ return A.map((a, i) => {
11
+ let best = -1, d = Infinity;
12
+ B.forEach((b, j) => { const x = distance(a, b); if (x < d) { d = x; best = j; } });
13
+ return { i, j: best, metros: Number.isFinite(d) ? d : null };
14
+ });
15
+ }
16
+
17
+ // quantos pontos ficam a até `metros` de um centro
18
+ export const withinRadius = (points, center, metros) => points.map((p, i) => [i, distance(p, center)]).filter(([, d]) => d <= metros).map(([i]) => i);
19
+
20
+ // ponto dentro de polígono (anéis GeoJSON [lon, lat]; o primeiro é o contorno, os outros são buracos)
21
+ function inRing([lat, lon], ring) {
22
+ let inside = false;
23
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
24
+ const [xi, yi] = ring[i], [xj, yj] = ring[j];
25
+ if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
26
+ }
27
+ return inside;
28
+ }
29
+ export function inPolygon(p, geometry) {
30
+ const polys = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.type === "MultiPolygon" ? geometry.coordinates : [];
31
+ return polys.some((rings) => inRing(p, rings[0]) && !rings.slice(1).some((h) => inRing(p, h)));
32
+ }
33
+
34
+ // distância de um ponto a um segmento (metros), numa projeção local equiretangular (boa até dezenas de km)
35
+ function toXY([lat, lon], lat0) { return [lon * rad * R * Math.cos(lat0 * rad), lat * rad * R]; }
36
+ export function pointSegment(p, a, b) {
37
+ const lat0 = p[0], [px, py] = toXY(p, lat0), [ax, ay] = toXY(a, lat0), [bx, by] = toXY(b, lat0);
38
+ const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
39
+ const t = L ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0;
40
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
41
+ }
42
+ // distância de um ponto a uma linha GeoJSON (LineString ou MultiLineString)
43
+ export function pointLine(p, geometry) {
44
+ const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.type === "MultiLineString" ? geometry.coordinates : [];
45
+ let d = Infinity;
46
+ for (const l of lines) for (let k = 1; k < l.length; k++) d = Math.min(d, pointSegment(p, [l[k - 1][1], l[k - 1][0]], [l[k][1], l[k][0]]));
47
+ return d;
48
+ }
49
+
50
+ // círculo como polígono (para raio e para a faixa): n vértices
51
+ export function circle([lat, lon], metros, n = 32) {
52
+ const ring = [];
53
+ for (let k = 0; k <= n; k++) {
54
+ const a = (2 * Math.PI * k) / n;
55
+ ring.push([lon + (metros * Math.sin(a)) / (R * Math.cos(lat * rad)) / rad, lat + (metros * Math.cos(a)) / R / rad]);
56
+ }
57
+ return { type: "Polygon", coordinates: [ring] };
58
+ }
59
+
60
+ // faixa de `metros` em volta de uma linha, para DESENHAR: retângulos dos trechos e círculos nos vértices (MultiPolygon).
61
+ // Para CONTAR o que está dentro, use pointLine (exato).
62
+ export function lineBuffer(geometry, metros) {
63
+ const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.type === "MultiLineString" ? geometry.coordinates : [];
64
+ const polys = [];
65
+ for (const l of lines) {
66
+ for (let k = 0; k < l.length; k++) {
67
+ polys.push(circle([l[k][1], l[k][0]], metros, 16).coordinates);
68
+ if (!k) continue;
69
+ const [lon1, lat1] = l[k - 1], [lon2, lat2] = l[k];
70
+ const lat0 = (lat1 + lat2) / 2, kx = 1 / (R * Math.cos(lat0 * rad)) / rad, ky = 1 / R / rad;
71
+ const dx = (lon2 - lon1) / kx, dy = (lat2 - lat1) / ky, len = Math.hypot(dx, dy) || 1;
72
+ const ox = (-dy / len) * metros * kx, oy = (dx / len) * metros * ky;
73
+ polys.push([[[lon1 + ox, lat1 + oy], [lon2 + ox, lat2 + oy], [lon2 - ox, lat2 - oy], [lon1 - ox, lat1 - oy], [lon1 + ox, lat1 + oy]]]);
74
+ }
75
+ }
76
+ return { type: "MultiPolygon", coordinates: polys };
77
+ }
78
+
79
+ // "onde instalar o próximo": os `quantos` lugares mais longe de todos os existentes (e uns dos outros), dentro da área
80
+ // (polígono) ou do retângulo que cobre os existentes. Grade + escolha gulosa; devolve [{ lat, lon, metros }] com a
81
+ // distância até o existente (ou sugerido) mais próximo.
82
+ export function suggestSites(existing, { area = null, quantos = 3, grade = 40 } = {}) {
83
+ let s = 90, w = 180, n = -90, e = -180;
84
+ const add = ([lat, lon]) => { s = Math.min(s, lat); n = Math.max(n, lat); w = Math.min(w, lon); e = Math.max(e, lon); };
85
+ if (area) { const walk = (c) => (typeof c[0] === "number" ? add([c[1], c[0]]) : c.forEach(walk)); walk(area.coordinates); } else existing.forEach(add);
86
+ if (s > n) return [];
87
+ // pontos alinhados (todos na mesma latitude ou longitude): o retângulo ganha a metade do outro lado de folga
88
+ if (!area) { const padLat = n - s < 1e-9 ? Math.max((e - w) / 2, 0.005) : 0, padLon = e - w < 1e-9 ? Math.max((n - s) / 2, 0.005) : 0; s -= padLat; n += padLat; w -= padLon; e += padLon; }
89
+ if (!(n > s) || !(e > w)) return [];
90
+ const cand = [];
91
+ for (let i = 0; i < grade; i++) for (let j = 0; j < grade; j++) {
92
+ const p = [s + ((n - s) * (i + 0.5)) / grade, w + ((e - w) * (j + 0.5)) / grade];
93
+ if (!area || inPolygon(p, area)) cand.push(p);
94
+ }
95
+ const chosen = [], base = [...existing];
96
+ for (let k = 0; k < quantos && cand.length; k++) {
97
+ let best = null, bd = -1;
98
+ for (const c of cand) { const d = base.length ? Math.min(...base.map((b) => distance(c, b))) : Infinity; if (d > bd) { bd = d; best = c; } }
99
+ if (!best) break;
100
+ chosen.push({ lat: best[0], lon: best[1], metros: Number.isFinite(bd) ? bd : null });
101
+ base.push(best);
102
+ }
103
+ return chosen;
104
+ }
105
+
106
+ export const fmtMetros = (m) => (m == null ? "?" : m >= 1000 ? `${(m / 1000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} km` : `${Math.round(m)} m`);
@@ -0,0 +1,83 @@
1
+ // Os serviços do mapa desta máquina (~/.sagadeck/mapa.json, ou SAGADECK_MAPA): tiles do fundo, satélite, endereço↔
2
+ // coordenada, busca de lugares e rotas. Fora do código e fora da biblioteca, como a configuração da IA. Sem arquivo,
3
+ // valem os serviços públicos gratuitos (OpenStreetMap e companhia), com as regras de uso deles respeitadas pelo
4
+ // sagadeck (identificação, atribuição visível, um pedido por segundo no Nominatim, nada de baixar tiles fora da tela).
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { renameRetry } from "./fs-retry.js";
9
+
10
+ export function mapaFile(env = process.env, home = os.homedir()) {
11
+ return env.SAGADECK_MAPA || path.join(home, ".sagadeck", "mapa.json");
12
+ }
13
+
14
+ const OSM = "© colaboradores do OpenStreetMap";
15
+ export const MAP_DEFAULTS = {
16
+ // fundo de ruas; "claro" e "escuro" são o mesmo fundo com filtro de cor (sem outro provedor)
17
+ tiles: { url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: OSM, maxZoom: 19 },
18
+ // satélite só com provedor configurado (costuma pedir chave); sem ele, o slide usa as ruas e avisa
19
+ satelite: null,
20
+ geocoder: { provedor: "nominatim", url: "https://nominatim.openstreetmap.org", porSegundo: 1 },
21
+ busca: { provedor: "overpass", url: "https://overpass-api.de/api/interpreter" },
22
+ rotas: { provedor: "osrm", url: "https://router.project-osrm.org" },
23
+ };
24
+
25
+ const str = (v, max = 500) => (typeof v === "string" ? v.trim().slice(0, max) : "");
26
+ const service = (raw, base) => {
27
+ if (raw === null) return null;
28
+ if (!raw || typeof raw !== "object") return base;
29
+ const out = { ...(base || {}) };
30
+ for (const [k, v] of Object.entries(raw)) if (typeof v === "string") out[k] = str(v, 1000); else if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
31
+ return out.url ? out : base;
32
+ };
33
+
34
+ // a configuração que vale: o arquivo por cima do padrão, campo a campo (arquivo editado à mão não quebra nada). A
35
+ // chave pode vir de uma variável de ambiente indicada em keyEnv (no servidor), como no ia.json.
36
+ export function loadMapConfig(file = mapaFile(), env = process.env) {
37
+ let raw = {};
38
+ try { raw = JSON.parse(fs.readFileSync(file, "utf8")) || {}; } catch { raw = {}; }
39
+ return Object.fromEntries(Object.entries(MAP_DEFAULTS).map(([k, base]) => {
40
+ const s = service(raw[k], base);
41
+ if (s && !s.key && s.keyEnv && env[s.keyEnv]) return [k, { ...s, key: env[s.keyEnv], keyFrom: "variável" }];
42
+ return [k, s];
43
+ }));
44
+ }
45
+
46
+ // grava o que veio da tela Configurar mapa por cima do arquivo (campo a campo; chave em branco mantém a gravada),
47
+ // de forma atômica e só para quem é dono da conta (as chaves moram aqui)
48
+ export function saveMapConfig(patch, file = mapaFile()) {
49
+ let cur = {};
50
+ try { cur = JSON.parse(fs.readFileSync(file, "utf8")) || {}; } catch { cur = {}; }
51
+ for (const [k, v] of Object.entries(patch || {})) {
52
+ if (!(k in MAP_DEFAULTS)) continue;
53
+ if (v === null) { cur[k] = null; continue; }
54
+ if (!v || typeof v !== "object") continue;
55
+ const next = { ...(cur[k] && typeof cur[k] === "object" ? cur[k] : {}) };
56
+ for (const [f, x] of Object.entries(v)) {
57
+ if (f === "key" && !String(x ?? "").trim()) continue; // em branco: mantém a chave gravada
58
+ if (typeof x === "string") next[f] = str(x, 1000); else if (typeof x === "number" && Number.isFinite(x)) next[f] = x;
59
+ }
60
+ cur[k] = next;
61
+ }
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ const tmp = `${file}.tmp-${process.pid}`;
64
+ fs.writeFileSync(tmp, JSON.stringify(cur, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
65
+ renameRetry(tmp, file);
66
+ return loadMapConfig(file);
67
+ }
68
+
69
+ // para a tela: a chave nunca sai inteira
70
+ export function maskedMapConfig(cfg) {
71
+ return Object.fromEntries(Object.entries(cfg).map(([k, v]) => {
72
+ if (!v) return [k, v];
73
+ const { key, ...rest } = v;
74
+ return [k, { ...rest, keySet: !!key, keyHint: key ? `••••${String(key).slice(-4)}` : "", keyFrom: key ? rest.keyFrom || "arquivo" : "" }];
75
+ }));
76
+ }
77
+
78
+ // o que pode ir para dentro do HTML da apresentação: só o fundo (o navegador busca os tiles). Chave de rotas e de
79
+ // geocodificação nunca vai para o deck.
80
+ export function tilesForDeck(cfg = loadMapConfig()) {
81
+ const pick = (t) => (t ? { url: t.url, attribution: t.attribution || OSM, maxZoom: t.maxZoom || 19 } : null);
82
+ return { tiles: pick(cfg.tiles), satelite: pick(cfg.satelite) };
83
+ }