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.
- package/README.md +40 -0
- package/docs/ANDAMENTO.md +122 -0
- package/docs/REFERENCIA.md +58 -0
- package/package.json +2 -1
- package/src/ai/deck-ai.js +13 -4
- package/src/ai/map-analysis-run.js +119 -0
- package/src/ai/map-data.js +221 -0
- package/src/build.js +6 -2
- 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/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/runtime/base.css +20 -0
- package/src/runtime/map.js +234 -0
- package/src/runtime/runtime.js +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/demo-decks.js +3 -1
- package/src/studio/geo-routes.js +138 -0
- package/src/studio/layout-samples.js +71 -0
- package/src/studio/project.js +1 -1
- package/src/studio/public/ai-settings.js +4 -1
- package/src/studio/public/app.js +7 -3
- package/src/studio/public/explorer.js +23 -8
- package/src/studio/public/index.html +4 -0
- package/src/studio/public/library.html +2 -0
- package/src/studio/public/library.js +6 -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 +10 -0
- 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
|
-
|
|
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/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
|
},
|
|
@@ -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
|
+
}
|