sagadeck 1.7.1 → 1.9.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 +55 -4
- package/SKILL.md +10 -3
- package/bin/sagadeck.js +23 -0
- package/docs/ANDAMENTO.md +483 -1
- package/docs/PLANO-MELHORIAS.md +180 -0
- package/docs/REFERENCIA.md +184 -105
- package/package.json +34 -2
- package/src/ai/commands.js +2 -2
- package/src/ai/deck-ai.js +14 -5
- package/src/ai/ia-check.js +127 -0
- package/src/ai/ia-config.js +2 -2
- package/src/ai/llm.js +5 -1
- package/src/ai/map-analysis-run.js +119 -0
- package/src/ai/map-data.js +221 -0
- package/src/ai/quality.js +1 -1
- package/src/ai/video-generation.js +70 -25
- package/src/build.js +51 -15
- package/src/elements.js +39 -1
- package/src/export/browser.js +23 -1
- package/src/export/pptx.js +9 -3
- package/src/export/shots.js +10 -3
- package/src/fiscal/normalize.js +19 -0
- package/src/fs-retry.js +37 -2
- package/src/import/office.js +5 -4
- package/src/layouts.js +114 -99
- package/src/lessons.js +1 -1
- package/src/library.js +7 -7
- package/src/map-analysis.js +109 -0
- package/src/map-config.js +89 -0
- package/src/map-routing.js +82 -0
- package/src/map-services.js +60 -0
- package/src/map.js +274 -0
- package/src/mcp/server.js +17 -0
- package/src/motion.js +20 -6
- package/src/purpose.js +1 -1
- package/src/runtime/base.css +105 -329
- package/src/runtime/fit.js +3 -2
- package/src/runtime/fonts/amatic-sc.css +3 -0
- package/src/runtime/fonts/bebas-neue.css +2 -0
- package/src/runtime/fonts/cabin-sketch.css +2 -0
- package/src/runtime/fonts/fraunces.css +3 -0
- package/src/runtime/fonts/gelasio.css +3 -0
- package/src/runtime/fonts/ibm-plex-mono.css +4 -0
- package/src/runtime/fonts/index.json +10 -1
- package/src/runtime/fonts/itim.css +2 -0
- package/src/runtime/fonts/montserrat.css +2 -0
- package/src/runtime/fonts/space-grotesk.css +2 -0
- package/src/runtime/map-background.js +91 -0
- package/src/runtime/map.js +266 -0
- package/src/runtime/runtime.js +81 -100
- package/src/runtime/skins/caderno.css +33 -0
- package/src/runtime/skins/cartaz.css +27 -0
- package/src/runtime/skins/infografico.css +29 -0
- package/src/runtime/skins/lousa.css +22 -0
- package/src/runtime/skins/musgo.css +42 -0
- package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
- package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
- package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -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/runtime/vendor/maplibre-gl.css +1 -0
- package/src/runtime/vendor/maplibre-gl.js +825 -0
- package/src/runtime/vendor/maplibre-leaflet.js +234 -0
- package/src/runtime/vendor/maplibre-worker.js +10 -0
- package/src/studio/ai-chat-route.js +20 -0
- package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
- package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
- package/src/studio/command-approval.js +2 -3
- package/src/studio/demo-decks.js +6 -2
- package/src/studio/export-jobs.js +86 -0
- package/src/studio/exporting.js +36 -18
- package/src/studio/feature-demos.js +144 -0
- package/src/studio/geo-routes.js +138 -0
- package/src/studio/ia-routes.js +9 -0
- package/src/studio/layout-samples.js +78 -16
- package/src/studio/library-routes.js +0 -9
- package/src/studio/project.js +2 -1
- package/src/studio/public/ai-settings.js +6 -3
- package/src/studio/public/app.js +46 -52
- package/src/studio/public/explorer.js +23 -8
- package/src/studio/public/export-status.js +154 -0
- package/src/studio/public/index.html +17 -10
- package/src/studio/public/library.css +12 -3
- package/src/studio/public/library.html +3 -0
- package/src/studio/public/library.js +39 -12
- package/src/studio/public/map-data.js +158 -0
- package/src/studio/public/map-editor.js +676 -0
- package/src/studio/public/map-fields.js +21 -0
- package/src/studio/public/map-settings.js +86 -0
- package/src/studio/public/slide-form.js +19 -16
- package/src/studio/public/style.css +58 -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 +34 -9
- package/src/studio/snapshot.js +8 -4
- package/src/studio/template-collections.js +1 -1
- package/src/studio/tour.js +158 -0
- package/src/themes.js +154 -0
- package/templates/cenario-e-ciencia.yaml +1 -21
- package/templates/ensaio-api.yaml +3 -3
- package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
- package/templates/cenario/imagens/cidade.svg +0 -30
- package/templates/cenario/imagens/pessoa.svg +0 -15
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
const OFM = '<a href="https://openfreemap.org/" target="_blank" rel="noopener">OpenFreeMap</a> · © <a href="https://openmaptiles.org/" target="_blank" rel="noopener">OpenMapTiles</a> · © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">colaboradores do OpenStreetMap</a>';
|
|
16
|
+
export const MAP_DEFAULTS = {
|
|
17
|
+
// ruas conserva o OpenStreetMap; fundos de dados são vetoriais, sem a fronteira marítima desenhada no mar
|
|
18
|
+
tiles: { url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: OSM, maxZoom: 19 },
|
|
19
|
+
claro: { url: "https://tiles.openfreemap.org/styles/positron", tipo: "vetorial", attribution: OFM, maxZoom: 20 },
|
|
20
|
+
escuro: { url: "https://tiles.openfreemap.org/styles/dark", tipo: "vetorial", attribution: OFM, maxZoom: 20 },
|
|
21
|
+
// satélite só com provedor configurado (costuma pedir chave); sem ele, o slide usa as ruas e avisa
|
|
22
|
+
satelite: null,
|
|
23
|
+
geocoder: { provedor: "nominatim", url: "https://nominatim.openstreetmap.org", porSegundo: 1 },
|
|
24
|
+
busca: { provedor: "overpass", url: "https://overpass-api.de/api/interpreter" },
|
|
25
|
+
rotas: { provedor: "osrm", url: "https://router.project-osrm.org" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const str = (v, max = 500) => (typeof v === "string" ? v.trim().slice(0, max) : "");
|
|
29
|
+
const service = (raw, base) => {
|
|
30
|
+
if (raw === null) return null;
|
|
31
|
+
if (!raw || typeof raw !== "object") return base;
|
|
32
|
+
const out = { ...(base || {}) };
|
|
33
|
+
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;
|
|
34
|
+
return out.url ? out : base;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// a configuração que vale: o arquivo por cima do padrão, campo a campo (arquivo editado à mão não quebra nada). A
|
|
38
|
+
// chave pode vir de uma variável de ambiente indicada em keyEnv (no servidor), como no ia.json.
|
|
39
|
+
export function loadMapConfig(file = mapaFile(), env = process.env) {
|
|
40
|
+
let raw = {};
|
|
41
|
+
try { raw = JSON.parse(fs.readFileSync(file, "utf8")) || {}; } catch { raw = {}; }
|
|
42
|
+
return Object.fromEntries(Object.entries(MAP_DEFAULTS).map(([k, base]) => {
|
|
43
|
+
let s = service(raw[k], base);
|
|
44
|
+
// arquivo antigo com tiles próprios (ou desligados): respeita esse fundo nos três modos
|
|
45
|
+
if (["claro", "escuro"].includes(k) && !Object.hasOwn(raw, k) && Object.hasOwn(raw, "tiles") && (raw.tiles === null || raw.tiles?.url && raw.tiles.url !== MAP_DEFAULTS.tiles.url)) s = service(raw.tiles, MAP_DEFAULTS.tiles);
|
|
46
|
+
if (s && ["tiles", "claro", "escuro", "satelite"].includes(k)) s = { ...s, tipo: s.url.includes("{z}") ? "raster" : s.tipo || "raster" };
|
|
47
|
+
if (s && !s.key && s.keyEnv && env[s.keyEnv]) return [k, { ...s, key: env[s.keyEnv], keyFrom: "variável" }];
|
|
48
|
+
return [k, s];
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// grava o que veio da tela Configurar mapa por cima do arquivo (campo a campo; chave em branco mantém a gravada),
|
|
53
|
+
// de forma atômica e só para quem é dono da conta (as chaves moram aqui)
|
|
54
|
+
export function saveMapConfig(patch, file = mapaFile()) {
|
|
55
|
+
let cur = {};
|
|
56
|
+
try { cur = JSON.parse(fs.readFileSync(file, "utf8")) || {}; } catch { cur = {}; }
|
|
57
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
58
|
+
if (!(k in MAP_DEFAULTS)) continue;
|
|
59
|
+
if (v === null) { cur[k] = null; continue; }
|
|
60
|
+
if (!v || typeof v !== "object") continue;
|
|
61
|
+
const next = { ...(cur[k] && typeof cur[k] === "object" ? cur[k] : {}) };
|
|
62
|
+
for (const [f, x] of Object.entries(v)) {
|
|
63
|
+
if (f === "key" && !String(x ?? "").trim()) continue; // em branco: mantém a chave gravada
|
|
64
|
+
if (typeof x === "string") next[f] = str(x, 1000); else if (typeof x === "number" && Number.isFinite(x)) next[f] = x;
|
|
65
|
+
}
|
|
66
|
+
cur[k] = next;
|
|
67
|
+
}
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
70
|
+
fs.writeFileSync(tmp, JSON.stringify(cur, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
71
|
+
renameRetry(tmp, file);
|
|
72
|
+
return loadMapConfig(file);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// para a tela: a chave nunca sai inteira
|
|
76
|
+
export function maskedMapConfig(cfg) {
|
|
77
|
+
return Object.fromEntries(Object.entries(cfg).map(([k, v]) => {
|
|
78
|
+
if (!v) return [k, v];
|
|
79
|
+
const { key, ...rest } = v;
|
|
80
|
+
return [k, { ...rest, keySet: !!key, keyHint: key ? `••••${String(key).slice(-4)}` : "", keyFrom: key ? rest.keyFrom || "arquivo" : "" }];
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// o que pode ir para dentro do HTML da apresentação: só o fundo (o navegador busca os tiles). Chave de rotas e de
|
|
85
|
+
// geocodificação nunca vai para o deck.
|
|
86
|
+
export function tilesForDeck(cfg = loadMapConfig()) {
|
|
87
|
+
const pick = (t) => (t ? { url: t.url, tipo: t.tipo || "raster", attribution: t.attribution || OSM, maxZoom: t.maxZoom || 19 } : null);
|
|
88
|
+
return { tiles: pick(cfg.tiles), claro: pick(cfg.claro === undefined ? cfg.tiles : cfg.claro), escuro: pick(cfg.escuro === undefined ? cfg.tiles : cfg.escuro), satelite: pick(cfg.satelite) };
|
|
89
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Rotas pelas ruas e endereço de um ponto, para o editor do mapa (entrega 3) e as análises (entrega 5). Passam por
|
|
2
|
+
// callService (src/map-services.js): identificação do sagadeck e "uma tentativa e para".
|
|
3
|
+
// - rota: OpenRouteService quando configurado com chave (a pé, de carro, de bicicleta; também isócronas) ou OSRM
|
|
4
|
+
// (padrão público; o servidor de demonstração só tem carro de forma garantida);
|
|
5
|
+
// - endereço de um ponto: Nominatim /reverse (ou compatível).
|
|
6
|
+
import { loadMapConfig, mapaFile } from "./map-config.js";
|
|
7
|
+
import { callService } from "./map-services.js";
|
|
8
|
+
|
|
9
|
+
export const MODOS = { pe: "a pé", carro: "de carro", bicicleta: "de bicicleta" };
|
|
10
|
+
const ORS_PROFILE = { pe: "foot-walking", carro: "driving-car", bicicleta: "cycling-regular" };
|
|
11
|
+
const OSRM_PROFILE = { pe: "foot", carro: "driving", bicicleta: "bike" };
|
|
12
|
+
|
|
13
|
+
const isORS = (r) => r && (r.provedor === "openrouteservice" || /openrouteservice/i.test(r.url || ""));
|
|
14
|
+
|
|
15
|
+
// pontos: [[lat, lon], …] (2 ou mais). Devolve { coords: [[lon, lat], …], distancia (m), duracao (s), modo }
|
|
16
|
+
export async function route(pontos, { modo = "pe", cfg = loadMapConfig(mapaFile()) } = {}) {
|
|
17
|
+
if (!Array.isArray(pontos) || pontos.length < 2) throw new Error("Rota: preciso de pelo menos dois pontos.");
|
|
18
|
+
const pts = pontos.map(([lat, lon]) => [Number(lat), Number(lon)]);
|
|
19
|
+
if (pts.some(([a, b]) => !Number.isFinite(a) || !Number.isFinite(b))) throw new Error("Rota: ponto sem coordenada.");
|
|
20
|
+
const r = cfg.rotas;
|
|
21
|
+
const m = MODOS[modo] ? modo : "pe";
|
|
22
|
+
if (isORS(r) && r.key) {
|
|
23
|
+
const base = (r.url || "https://api.openrouteservice.org").replace(/\/+$/, "");
|
|
24
|
+
const res = await callService("rotas", `${base}/v2/directions/${ORS_PROFILE[m]}/geojson`, {
|
|
25
|
+
method: "POST", cfg, headers: { Authorization: r.key, "Content-Type": "application/json", Accept: "application/json, application/geo+json" },
|
|
26
|
+
body: JSON.stringify({ coordinates: pts.map(([lat, lon]) => [lon, lat]) }),
|
|
27
|
+
});
|
|
28
|
+
const j = await res.json();
|
|
29
|
+
const f = j.features?.[0];
|
|
30
|
+
if (!f) throw new Error("Rota: o serviço não achou caminho entre os pontos.");
|
|
31
|
+
return { coords: f.geometry.coordinates, distancia: f.properties?.summary?.distance ?? 0, duracao: f.properties?.summary?.duration ?? 0, modo: m };
|
|
32
|
+
}
|
|
33
|
+
const base = (r?.url || "https://router.project-osrm.org").replace(/\/+$/, "");
|
|
34
|
+
const res = await callService("rotas", `${base}/route/v1/${OSRM_PROFILE[m]}/${pts.map(([lat, lon]) => `${lon},${lat}`).join(";")}?overview=full&geometries=geojson`, { cfg });
|
|
35
|
+
const j = await res.json();
|
|
36
|
+
const rt = j.routes?.[0];
|
|
37
|
+
if (!rt) throw new Error("Rota: o serviço não achou caminho entre os pontos.");
|
|
38
|
+
return { coords: rt.geometry.coordinates, distancia: rt.distance, duracao: rt.duration, modo: m };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// área alcançável em X minutos a partir de um ponto (só OpenRouteService): GeoJSON Polygon
|
|
42
|
+
export async function isochrone([lat, lon], minutos, { modo = "pe", cfg = loadMapConfig(mapaFile()) } = {}) {
|
|
43
|
+
const r = cfg.rotas;
|
|
44
|
+
if (!isORS(r) || !r.key) throw new Error("Área alcançável precisa do OpenRouteService com chave (Configurar mapa).");
|
|
45
|
+
const base = (r.url || "https://api.openrouteservice.org").replace(/\/+$/, "");
|
|
46
|
+
const res = await callService("rotas", `${base}/v2/isochrones/${ORS_PROFILE[MODOS[modo] ? modo : "pe"]}`, {
|
|
47
|
+
method: "POST", cfg, headers: { Authorization: r.key, "Content-Type": "application/json", Accept: "application/json, application/geo+json" },
|
|
48
|
+
body: JSON.stringify({ locations: [[Number(lon), Number(lat)]], range: [Math.round(Number(minutos) * 60)], range_type: "time" }),
|
|
49
|
+
});
|
|
50
|
+
const j = await res.json();
|
|
51
|
+
const f = j.features?.[0];
|
|
52
|
+
if (!f) throw new Error("Área alcançável: o serviço não devolveu a área.");
|
|
53
|
+
return f.geometry;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// rastro de GPS tremido -> o caminho pelas ruas por onde passou (OSRM /match; com o OpenRouteService configurado
|
|
57
|
+
// para rotas, o encaixe usa o OSRM público). pontos: [[lat, lon], …] (até 100). Devolve [[lon, lat], …]
|
|
58
|
+
export async function matchTrace(pontos, { modo = "pe", cfg = loadMapConfig(mapaFile()) } = {}) {
|
|
59
|
+
const pts = pontos.slice(0, 100).map(([lat, lon]) => [Number(lat), Number(lon)]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b));
|
|
60
|
+
if (pts.length < 2) throw new Error("Encaixar nas ruas: o rastro precisa de pelo menos dois pontos.");
|
|
61
|
+
const base = (!isORS(cfg.rotas) && cfg.rotas?.url ? cfg.rotas.url : "https://router.project-osrm.org").replace(/\/+$/, "");
|
|
62
|
+
const res = await callService("rotas", `${base}/match/v1/${OSRM_PROFILE[MODOS[modo] ? modo : "pe"]}/${pts.map(([lat, lon]) => `${lon},${lat}`).join(";")}?overview=full&geometries=geojson`, { cfg });
|
|
63
|
+
const j = await res.json();
|
|
64
|
+
const coords = (j.matchings || []).flatMap((m) => m.geometry?.coordinates || []);
|
|
65
|
+
if (coords.length < 2) throw new Error("Encaixar nas ruas: o serviço não achou as ruas desse rastro.");
|
|
66
|
+
return coords;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// endereço de um ponto (para preencher a coluna de endereço de um ponto novo)
|
|
70
|
+
export async function reverseGeocode(lat, lon, { cfg = loadMapConfig(mapaFile()) } = {}) {
|
|
71
|
+
const g = cfg.geocoder;
|
|
72
|
+
const res = await callService("geocoder", `${g.url.replace(/\/+$/, "")}/reverse?format=jsonv2&lat=${Number(lat)}&lon=${Number(lon)}${g.key ? `&key=${encodeURIComponent(g.key)}` : ""}`, { cfg });
|
|
73
|
+
const j = await res.json();
|
|
74
|
+
return j?.display_name || "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// distância em metros entre dois pontos [lat, lon] (haversine): a régua e o "mais próximo" (entrega 5)
|
|
78
|
+
export function distance([lat1, lon1], [lat2, lon2]) {
|
|
79
|
+
const R = 6371008.8, rad = Math.PI / 180;
|
|
80
|
+
const a = Math.sin(((lat2 - lat1) * rad) / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(((lon2 - lon1) * rad) / 2) ** 2;
|
|
81
|
+
return 2 * R * Math.asin(Math.sqrt(a));
|
|
82
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Chamadas aos serviços do mapa (endereço↔coordenada, busca de lugares, rotas), feitas pelo servidor do Studio com a
|
|
2
|
+
// identificação do sagadeck (as políticas de uso pedem). Regras:
|
|
3
|
+
// - uma tentativa e para: erro de autorização ou bloqueio (401, 403, 407, 429, recusa, certificado, tempo esgotado)
|
|
4
|
+
// marca o serviço indisponível nesta execução, e nenhum pedido sai até alguém pedir "Tentar de novo";
|
|
5
|
+
// - sem teste proativo: o estado começa "não usado" e só muda com uso de verdade;
|
|
6
|
+
// - o estado de cada serviço vai para a IA (ela decide o que chamar) e para a tela Configurar mapa.
|
|
7
|
+
import { loadMapConfig, mapaFile } from "./map-config.js";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const VERSION = (() => { try { return require("../package.json").version; } catch { return "?"; } })();
|
|
12
|
+
export const USER_AGENT = `sagadeck/${VERSION} (+https://github.com/naruminho/sagadeck)`;
|
|
13
|
+
export const SERVICES = { geocoder: "Endereço e coordenada", busca: "Busca de lugares", rotas: "Rotas" };
|
|
14
|
+
|
|
15
|
+
const state = new Map(); // serviço -> { status: "ok" | "bloqueado", motivo, quando }
|
|
16
|
+
export const serviceState = () => Object.fromEntries(Object.keys(SERVICES).map((k) => [k, state.get(k) || { status: "não usado" }]));
|
|
17
|
+
export const resetService = (k) => (k ? state.delete(k) : state.clear());
|
|
18
|
+
|
|
19
|
+
export class ServiceBlocked extends Error {
|
|
20
|
+
constructor(service, motivo) { super(`${SERVICES[service] || service}: ${motivo}`); this.service = service; this.motivo = motivo; this.code = "MAP_SERVICE_BLOCKED"; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hhmm = () => new Date().toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
|
|
24
|
+
const BLOCK = { 401: "recusou a autorização (401)", 403: "recusou o acesso (403)", 407: "o proxy da rede pediu autenticação (407)", 429: "pediu para diminuir o ritmo (429)" };
|
|
25
|
+
|
|
26
|
+
// um pedido a um serviço; se ele estiver bloqueado, nem tenta
|
|
27
|
+
export async function callService(service, url, { method = "GET", headers = {}, body, timeoutMs = 15000, cfg = loadMapConfig(mapaFile()) } = {}) {
|
|
28
|
+
const st = state.get(service);
|
|
29
|
+
if (st?.status === "bloqueado") throw new ServiceBlocked(service, `${st.motivo}; não tentei de novo`);
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetch(url, { method, body, headers: { "User-Agent": USER_AGENT, Accept: "application/json", ...headers }, signal: AbortSignal.timeout(timeoutMs) });
|
|
33
|
+
} catch (e) {
|
|
34
|
+
const motivo = e.name === "TimeoutError" ? "não respondeu a tempo" : /certificate|self[- ]signed|CERT/i.test(String(e.cause?.message || e.message)) ? "o certificado foi barrado pela rede" : "não foi possível conectar (a rede pode estar bloqueando)";
|
|
35
|
+
state.set(service, { status: "bloqueado", motivo: `${motivo} às ${hhmm()}`, quando: Date.now() });
|
|
36
|
+
throw new ServiceBlocked(service, motivo);
|
|
37
|
+
}
|
|
38
|
+
if (BLOCK[res.status]) {
|
|
39
|
+
state.set(service, { status: "bloqueado", motivo: `${BLOCK[res.status]} às ${hhmm()}`, quando: Date.now() });
|
|
40
|
+
throw new ServiceBlocked(service, BLOCK[res.status]);
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok) throw new Error(`${SERVICES[service] || service}: HTTP ${res.status}`);
|
|
43
|
+
state.set(service, { status: "ok", quando: Date.now() });
|
|
44
|
+
return res;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------------------------- endereço -> ponto
|
|
48
|
+
// Nominatim (padrão) ou outro compatível com /search?format=jsonv2. Um pedido por vez, no ritmo do serviço
|
|
49
|
+
// (porSegundo; o Nominatim público aceita 1). O cache é de quem chama (o projeto guarda o seu).
|
|
50
|
+
export async function geocodeOne(q, { cfg = loadMapConfig(mapaFile()), signal } = {}) {
|
|
51
|
+
const g = cfg.geocoder;
|
|
52
|
+
const url = `${g.url.replace(/\/+$/, "")}/search?format=jsonv2&limit=1&addressdetails=0&q=${encodeURIComponent(q)}${g.key ? `&key=${encodeURIComponent(g.key)}` : ""}`;
|
|
53
|
+
if (signal?.aborted) throw new Error("cancelado");
|
|
54
|
+
const res = await callService("geocoder", url, { cfg });
|
|
55
|
+
const list = await res.json();
|
|
56
|
+
const hit = Array.isArray(list) ? list[0] : null;
|
|
57
|
+
return hit ? { lat: Number(hit.lat), lon: Number(hit.lon), nome: hit.display_name || "" } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const geocodeInterval = (cfg = loadMapConfig(mapaFile())) => Math.max(1, Math.ceil(1000 / Math.max(0.1, Number(cfg.geocoder?.porSegundo) || 1)));
|
package/src/map.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Slide de mapa (layout: map). A planilha é a fonte, o mapa é a vista: cada camada lê um arquivo do projeto (CSV,
|
|
2
|
+
// GeoJSON, GPX, KML) ou valores por estado/país, e vira GeoJSON com o estilo já decidido (cor por categoria ou por
|
|
3
|
+
// valor, tamanho, rótulo, campos do cartão). Os dados vão dentro do HTML; o mapa ao vivo (src/runtime/map.js) desenha
|
|
4
|
+
// com o Leaflet, e uma prévia em SVG (sem fundo) serve às miniaturas e de reserva.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseCSV } from "./csv.js";
|
|
9
|
+
import { toNum } from "./science.js";
|
|
10
|
+
|
|
11
|
+
const VENDOR = path.join(path.dirname(fileURLToPath(import.meta.url)), "runtime", "vendor");
|
|
12
|
+
let contornos;
|
|
13
|
+
export const geoContornos = () => (contornos ||= JSON.parse(fs.readFileSync(path.join(VENDOR, "geo-contornos.json"), "utf8")));
|
|
14
|
+
|
|
15
|
+
// cores das categorias: a paleta do tema (o runtime resolve --s1…), na ordem
|
|
16
|
+
// cores das categorias: a paleta do tema (c1…c5, as mesmas da tabela e dos gráficos), depois destaque e alerta
|
|
17
|
+
export const MAP_COLORS = ["c1", "c2", "c3", "c4", "c5", "em", "hi", "alert"];
|
|
18
|
+
// token de cor -> CSS: hex, paleta do tema (--c-c1… com recuo no destaque), ou variável do tom (s1, em, hi, fg…)
|
|
19
|
+
export const mapColorCSS = (c) => (/^#?[0-9a-f]{6}$/i.test(c) ? `#${String(c).replace("#", "")}` : /^(c\d|accent|alert|ink|paper)$/.test(c) ? `var(--c-${c},var(--em))` : `var(--${c})`);
|
|
20
|
+
const BASEMAPS = ["ruas", "claro", "escuro", "satelite", "nenhum"];
|
|
21
|
+
|
|
22
|
+
const norm = (s) => String(s ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
23
|
+
const LAT = ["lat", "latitude", "y"], LON = ["lon", "lng", "long", "longitude", "x"], COORD = ["coordenadas", "coordenada", "coords", "latlon", "latlong", "posicao", "localizacao"];
|
|
24
|
+
const findCol = (cols, want, names) => {
|
|
25
|
+
if (want) { const i = cols.findIndex((c) => norm(c) === norm(want)); return i; }
|
|
26
|
+
return cols.findIndex((c) => names.includes(norm(c)));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------------------------- leitura dos arquivos
|
|
30
|
+
function readText(file, ctx, what) {
|
|
31
|
+
const full = path.resolve(ctx?.baseDir || process.cwd(), String(file).trim());
|
|
32
|
+
try { return fs.readFileSync(full, "utf8"); }
|
|
33
|
+
catch { ctx?.warnings?.push(`mapa: ${what} "${file}" não encontrado ao lado do deck`); return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// coordenada não tem separador de milhar: "-46.702" é -46,702 (o leitor de números da planilha leria -46702) e
|
|
37
|
+
// "-23,55" é -23,55 (vírgula decimal)
|
|
38
|
+
export function coordNum(v) {
|
|
39
|
+
if (typeof v === "number") return v;
|
|
40
|
+
const s = String(v ?? "").trim().replace(/\s/g, "");
|
|
41
|
+
return /^[+-]?\d+([.,]\d+)?$/.test(s) ? Number(s.replace(",", ".")) : NaN;
|
|
42
|
+
}
|
|
43
|
+
// "lat, lon" numa coluna só: "-23.55, -46.63", "-23,55; -46,63", "-23.55 -46.63"
|
|
44
|
+
export function splitCoords(v) {
|
|
45
|
+
const s = String(v ?? "").trim();
|
|
46
|
+
const parts = s.includes(";") ? s.split(";") : /,\s/.test(s) ? s.split(/,\s+/) : /\s/.test(s) ? s.split(/\s+/) : s.split(",");
|
|
47
|
+
return parts.length === 2 ? parts.map(coordNum) : [NaN, NaN];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// planilha (CSV) -> pontos. Colunas de posição reconhecidas pelo nome (lat/latitude, lon/longitude) ou indicadas.
|
|
51
|
+
export function pointsFromRows(head, rows, layer = {}, warn = () => {}) {
|
|
52
|
+
const iLat = findCol(head, layer.lat, LAT), iLon = findCol(head, layer.lon, LON);
|
|
53
|
+
const iCoord = iLat < 0 || iLon < 0 ? findCol(head, layer.coords, COORD) : -1;
|
|
54
|
+
if ((iLat < 0 || iLon < 0) && iCoord < 0) {
|
|
55
|
+
warn(`mapa: a planilha não tem colunas de posição (latitude e longitude); colunas: ${head.join(", ")}`);
|
|
56
|
+
return { features: [], missing: rows.length };
|
|
57
|
+
}
|
|
58
|
+
const features = [];
|
|
59
|
+
let missing = 0;
|
|
60
|
+
rows.forEach((r, n) => {
|
|
61
|
+
if (!r.some((c) => String(c ?? "").trim())) return; // linha vazia não é ponto sem posição
|
|
62
|
+
let lat, lon;
|
|
63
|
+
if (iCoord >= 0) [lat, lon] = splitCoords(r[iCoord]);
|
|
64
|
+
else { lat = coordNum(r[iLat]); lon = coordNum(r[iLon]); }
|
|
65
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) { missing++; return; }
|
|
66
|
+
// as colunas de posição não vão para o cartão (a posição já é o ponto no mapa)
|
|
67
|
+
const props = Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]).filter(([h], i) => h !== "" && i !== iLat && i !== iLon && i !== iCoord));
|
|
68
|
+
features.push({ type: "Feature", properties: { ...props, _linha: n + 2 }, geometry: { type: "Point", coordinates: [lon, lat] } });
|
|
69
|
+
});
|
|
70
|
+
if (missing) warn(`mapa: ${missing} linha${missing === 1 ? "" : "s"} da planilha sem posição válida ficaram de fora`);
|
|
71
|
+
return { features, missing };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const attr = (xml, name) => xml.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`))?.[1];
|
|
75
|
+
const tag = (xml, name) => xml.match(new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)</${name}>`))?.[1]?.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").trim();
|
|
76
|
+
const tags = (xml, name) => [...xml.matchAll(new RegExp(`<${name}\\b[^>]*?(?:/>|>([\\s\\S]*?)</${name}>)`, "g"))].map((m) => m[0]);
|
|
77
|
+
|
|
78
|
+
// GPX: trilhas e rotas viram linhas; pontos marcados (wpt) viram pontos
|
|
79
|
+
export function fromGPX(xml) {
|
|
80
|
+
const pt = (s) => [Number(attr(s, "lon")), Number(attr(s, "lat"))];
|
|
81
|
+
const features = [];
|
|
82
|
+
for (const t of [...tags(xml, "trk"), ...tags(xml, "rte")]) {
|
|
83
|
+
const segs = tags(t, "trkseg").length ? tags(t, "trkseg").map((s) => tags(s, "trkpt").map(pt)) : [tags(t, "rtept").map(pt)];
|
|
84
|
+
const lines = segs.filter((l) => l.length > 1);
|
|
85
|
+
if (!lines.length) continue;
|
|
86
|
+
features.push({ type: "Feature", properties: { nome: tag(t, "name") || "" }, geometry: lines.length === 1 ? { type: "LineString", coordinates: lines[0] } : { type: "MultiLineString", coordinates: lines } });
|
|
87
|
+
}
|
|
88
|
+
for (const w of tags(xml, "wpt")) features.push({ type: "Feature", properties: { nome: tag(w, "name") || "" }, geometry: { type: "Point", coordinates: pt(w) } });
|
|
89
|
+
return features;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// KML: cada Placemark com nome, descrição e dados (ExtendedData); ponto, linha ou polígono (inclusive MultiGeometry)
|
|
93
|
+
export function fromKML(xml) {
|
|
94
|
+
const coords = (s) => String(s || "").trim().split(/\s+/).map((c) => c.split(",").slice(0, 2).map(Number)).filter((c) => c.length === 2 && c.every(Number.isFinite));
|
|
95
|
+
const features = [];
|
|
96
|
+
for (const pm of tags(xml, "Placemark")) {
|
|
97
|
+
const props = { nome: tag(pm, "name") || "" };
|
|
98
|
+
const desc = tag(pm, "description"); if (desc) props.descricao = desc.replace(/<[^>]+>/g, " ").trim();
|
|
99
|
+
for (const d of tags(pm, "Data")) props[attr(d, "name") || "campo"] = tag(d, "value") || "";
|
|
100
|
+
for (const d of tags(pm, "SimpleData")) props[attr(d, "name") || "campo"] = d.replace(/<[^>]+>/g, "").trim();
|
|
101
|
+
const geoms = [
|
|
102
|
+
...tags(pm, "Point").map((p) => ({ type: "Point", coordinates: coords(tag(p, "coordinates"))[0] })),
|
|
103
|
+
...tags(pm, "LineString").map((l) => ({ type: "LineString", coordinates: coords(tag(l, "coordinates")) })),
|
|
104
|
+
...tags(pm, "Polygon").map((p) => ({ type: "Polygon", coordinates: [coords(tag(tag(p, "outerBoundaryIs") || p, "coordinates")), ...tags(p, "innerBoundaryIs").map((b) => coords(tag(b, "coordinates")))] })),
|
|
105
|
+
].filter((g) => g.coordinates && (g.type !== "Point" || g.coordinates.length === 2));
|
|
106
|
+
for (const g of geoms) features.push({ type: "Feature", properties: props, geometry: g });
|
|
107
|
+
}
|
|
108
|
+
return features;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fromGeoJSON(obj) {
|
|
112
|
+
if (!obj || typeof obj !== "object") return [];
|
|
113
|
+
// _i: a posição da forma no arquivo (o editor do Studio acha a forma clicada por ela)
|
|
114
|
+
if (obj.type === "FeatureCollection") return (obj.features || []).map((f, i) => (f && f.geometry ? { ...f, properties: { ...f.properties, _i: i } } : null)).filter(Boolean);
|
|
115
|
+
if (obj.type === "Feature") return obj.geometry ? [{ ...obj, properties: { ...obj.properties, _i: 0 } }] : [];
|
|
116
|
+
if (obj.type && obj.coordinates) return [{ type: "Feature", properties: { _i: 0 }, geometry: obj }];
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// valores por estado (sigla) ou país (código de 2 letras): o contorno embutido, pintado pelo valor
|
|
121
|
+
function fromAreas(values, warn) {
|
|
122
|
+
const g = geoContornos();
|
|
123
|
+
const features = [];
|
|
124
|
+
for (const [k, v] of Object.entries(values || {})) {
|
|
125
|
+
const code = String(k).trim().toUpperCase();
|
|
126
|
+
const uf = g.uf[code], pais = g.paises[code];
|
|
127
|
+
if (!uf && !pais) { warn(`mapa: "${k}" não é uma UF (SP, RJ…) nem um país (BR, PT…)`); continue; }
|
|
128
|
+
const poly = uf || pais.poly;
|
|
129
|
+
features.push({ type: "Feature", properties: { nome: uf ? code : pais.nome, valor: v, _cod: String(k) }, geometry: { type: "MultiPolygon", coordinates: poly } });
|
|
130
|
+
}
|
|
131
|
+
return features;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------------------------- estilo das camadas
|
|
135
|
+
// cor: uma coluna (categoria -> cores da paleta; número -> escala da cor de destaque), uma cor fixa, ou nada
|
|
136
|
+
function styleOf(features, layer, warn) {
|
|
137
|
+
const style = { color: null, colorBy: null, categories: null, scale: null, sizeBy: null, sizeScale: null, label: layer.label || null, popup: null, icon: layer.icon || null };
|
|
138
|
+
const props = features.map((f) => f.properties || {});
|
|
139
|
+
const has = (col) => col && props.some((p) => Object.hasOwn(p, col));
|
|
140
|
+
const by = layer.color ?? (layer.areas ? "valor" : null);
|
|
141
|
+
if (by && !has(by)) {
|
|
142
|
+
if (/^(#?[0-9a-f]{6}|s[1-5]|c\d|em|hi|alert|accent|fg|muted)$/i.test(String(by))) style.color = String(by);
|
|
143
|
+
else warn(`mapa: a camada "${layer.name || ""}" pinta pela coluna "${by}", que não existe`);
|
|
144
|
+
} else if (by) {
|
|
145
|
+
style.colorBy = by;
|
|
146
|
+
const vals = props.map((p) => p[by]);
|
|
147
|
+
const nums = vals.map(toNum).filter((n) => n != null);
|
|
148
|
+
if (nums.length === vals.filter((v) => String(v ?? "").trim() !== "").length && nums.length && !layer.colors) {
|
|
149
|
+
style.scale = { min: Math.min(...nums), max: Math.max(...nums) };
|
|
150
|
+
} else {
|
|
151
|
+
const order = [...new Set(vals.map((v) => String(v ?? "").trim()).filter(Boolean))];
|
|
152
|
+
const given = layer.colors && typeof layer.colors === "object" ? layer.colors : {};
|
|
153
|
+
if (order.length > MAP_COLORS.length && !Object.keys(given).length) warn(`mapa: "${by}" tem ${order.length} categorias; as cores se repetem a partir da ${MAP_COLORS.length + 1}ª (agrupe ou dê colors:)`);
|
|
154
|
+
style.categories = order.map((v, i) => ({ value: v, color: String(given[v] ?? MAP_COLORS[i % MAP_COLORS.length]) }));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (layer.size) {
|
|
158
|
+
if (!has(layer.size)) warn(`mapa: a camada "${layer.name || ""}" usa a coluna "${layer.size}" para o tamanho, que não existe`);
|
|
159
|
+
else {
|
|
160
|
+
const nums = props.map((p) => toNum(p[layer.size])).filter((n) => n != null);
|
|
161
|
+
if (nums.length) { style.sizeBy = layer.size; style.sizeScale = { min: Math.min(...nums), max: Math.max(...nums) }; }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (style.label && !has(style.label)) { warn(`mapa: a coluna "${style.label}" do rótulo não existe`); style.label = null; }
|
|
165
|
+
const cols = [...new Set(props.flatMap((p) => Object.keys(p)))].filter((c) => !c.startsWith("_"));
|
|
166
|
+
style.popup = Array.isArray(layer.popup) ? layer.popup.filter((c) => cols.includes(c)) : layer.popup === false ? [] : cols.slice(0, 8);
|
|
167
|
+
return style;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------------------------- o slide inteiro
|
|
171
|
+
function bboxOf(layers) {
|
|
172
|
+
let s = 90, w = 180, n = -90, e = -180, any = false;
|
|
173
|
+
const visit = (c) => {
|
|
174
|
+
if (typeof c[0] === "number") { const [lon, lat] = c; if (Number.isFinite(lat) && Number.isFinite(lon)) { any = true; s = Math.min(s, lat); n = Math.max(n, lat); w = Math.min(w, lon); e = Math.max(e, lon); } return; }
|
|
175
|
+
c.forEach(visit);
|
|
176
|
+
};
|
|
177
|
+
for (const l of layers) for (const f of l.data.features) if (f.geometry?.coordinates) visit(f.geometry.coordinates);
|
|
178
|
+
return any ? [[s, w], [n, e]] : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const viewOf = (v) => {
|
|
182
|
+
if (v === "fit" || v === true) return "fit";
|
|
183
|
+
if (v && Array.isArray(v.center) && v.center.length === 2 && v.center.every(Number.isFinite)) return { center: v.center, zoom: Number.isFinite(v.zoom) ? v.zoom : 13 };
|
|
184
|
+
return null;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export function mapModel(s, ctx) {
|
|
188
|
+
const warn = (m) => ctx?.warnings?.push(m);
|
|
189
|
+
const list = Array.isArray(s.layers) ? s.layers : s.layers ? [s.layers] : [];
|
|
190
|
+
const layers = [];
|
|
191
|
+
list.forEach((layer, i) => {
|
|
192
|
+
if (!layer || typeof layer !== "object") return;
|
|
193
|
+
let features = [], kind = "shapes";
|
|
194
|
+
if (layer.points != null) {
|
|
195
|
+
kind = "points";
|
|
196
|
+
if (typeof layer.points === "string") {
|
|
197
|
+
const text = readText(layer.points, ctx, "planilha");
|
|
198
|
+
if (text != null) {
|
|
199
|
+
// sem tirar as linhas vazias: _linha é o número da linha na planilha (a seleção liga linha e ponto)
|
|
200
|
+
const { rows } = parseCSV(text);
|
|
201
|
+
features = pointsFromRows((rows[0] || []).map((h) => String(h).trim()), rows.slice(1), layer, warn).features;
|
|
202
|
+
}
|
|
203
|
+
} else if (Array.isArray(layer.points)) {
|
|
204
|
+
const rows = layer.points.filter((p) => p && typeof p === "object");
|
|
205
|
+
const head = [...new Set(rows.flatMap((p) => Object.keys(p)))];
|
|
206
|
+
features = pointsFromRows(head, rows.map((p) => head.map((h) => p[h] ?? "")), layer, warn).features;
|
|
207
|
+
}
|
|
208
|
+
} else if (layer.geojson && typeof layer.geojson === "object") {
|
|
209
|
+
// escrito no próprio slide (desenho pequeno, demonstração); dados de verdade ficam num arquivo do projeto
|
|
210
|
+
features = fromGeoJSON(layer.geojson);
|
|
211
|
+
if (!features.length) warn(`mapa: o GeoJSON da camada "${layer.name || i + 1}" não tem formas`);
|
|
212
|
+
} else if (layer.geojson) {
|
|
213
|
+
const text = readText(layer.geojson, ctx, "GeoJSON");
|
|
214
|
+
try { features = text == null ? [] : fromGeoJSON(JSON.parse(text)); } catch { warn(`mapa: "${layer.geojson}" não é um GeoJSON válido`); }
|
|
215
|
+
} else if (layer.gpx) {
|
|
216
|
+
const text = readText(layer.gpx, ctx, "GPX"); features = text == null ? [] : fromGPX(text);
|
|
217
|
+
} else if (layer.kml) {
|
|
218
|
+
const text = readText(layer.kml, ctx, "KML"); features = text == null ? [] : fromKML(text);
|
|
219
|
+
} else if (layer.areas) {
|
|
220
|
+
kind = "areas"; features = fromAreas(layer.areas, warn);
|
|
221
|
+
} else { warn(`mapa: a camada ${i + 1} não diz de onde vêm os dados (points, geojson, gpx, kml ou areas)`); return; }
|
|
222
|
+
if (!features.length && layer.areas == null) warn(`mapa: a camada "${layer.name || i + 1}" ficou vazia`);
|
|
223
|
+
const step = layer.step ?? (s.build ? i + 1 : undefined);
|
|
224
|
+
layers.push({
|
|
225
|
+
name: layer.name || layer.title || `Camada ${i + 1}`, kind, index: i, // index: a camada no slide (o editor do Studio)
|
|
226
|
+
source: typeof layer.points === "string" ? layer.points.trim() : (typeof layer.geojson === "string" ? layer.geojson : null) || layer.gpx || layer.kml || null, step: Number.isFinite(Number(step)) ? Number(step) : undefined,
|
|
227
|
+
view: viewOf(layer.view), legend: layer.legend || null, prefix: layer.prefix || "", suffix: layer.suffix || "",
|
|
228
|
+
style: styleOf(features, layer, warn), data: { type: "FeatureCollection", features },
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
const basemap = BASEMAPS.includes(s.basemap) ? s.basemap : "ruas";
|
|
232
|
+
if (s.basemap && !BASEMAPS.includes(s.basemap)) warn(`mapa: fundo "${s.basemap}" não existe (use ${BASEMAPS.join(", ")})`);
|
|
233
|
+
// camadas da mesma medida (a mesma coluna ou a mesma unidade na legenda) dividem uma escala: cada uma com a sua,
|
|
234
|
+
// a Bahia (2.900) saía tão forte quanto Minas (5.100) só por ser a maior da camada dela
|
|
235
|
+
const groups = new Map();
|
|
236
|
+
for (const l of layers) if (l.style.scale) { const k = `${l.style.colorBy}|${l.legend || ""}`; (groups.get(k) || groups.set(k, []).get(k)).push(l); }
|
|
237
|
+
for (const g of groups.values()) if (g.length > 1) {
|
|
238
|
+
const scale = { min: Math.min(...g.map((l) => l.style.scale.min)), max: Math.max(...g.map((l) => l.style.scale.max)) };
|
|
239
|
+
for (const l of g) l.style.scale = scale;
|
|
240
|
+
}
|
|
241
|
+
return { basemap, view: viewOf(s.view), bounds: bboxOf(layers), layers, tiles: ctx?.mapTiles || null };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------------------------- prévia sem fundo
|
|
245
|
+
// SVG das camadas em Web Mercator, enquadradas: miniaturas do Studio (sem pedir tile nenhum) e reserva se o mapa ao
|
|
246
|
+
// vivo não montar
|
|
247
|
+
const merc = ([lon, lat]) => { const y = Math.log(Math.tan(Math.PI / 4 + (Math.max(-85, Math.min(85, lat)) * Math.PI) / 360)); return [lon / 360, -y / (2 * Math.PI)]; };
|
|
248
|
+
export function mapPreviewSVG(model, w = 1600, h = 760) {
|
|
249
|
+
if (!model.bounds) return `<svg class="map-preview" viewBox="0 0 ${w} ${h}" aria-hidden="true"></svg>`;
|
|
250
|
+
const [[s, wst], [n, e]] = model.bounds;
|
|
251
|
+
const [x0, y1] = merc([wst, s]), [x1, y0] = merc([e, n]);
|
|
252
|
+
const pad = 0.08, bw = Math.max(x1 - x0, 1e-6), bh = Math.max(y1 - y0, 1e-6);
|
|
253
|
+
const k = Math.min(w / (bw * (1 + 2 * pad)), h / (bh * (1 + 2 * pad)));
|
|
254
|
+
const ox = w / 2 - ((x0 + x1) / 2) * k, oy = h / 2 - ((y0 + y1) / 2) * k;
|
|
255
|
+
const P = (c) => { const [x, y] = merc(c); return `${(x * k + ox).toFixed(1)},${(y * k + oy).toFixed(1)}`; };
|
|
256
|
+
const out = [];
|
|
257
|
+
const color = (layer, f) => {
|
|
258
|
+
const st = layer.style, v = st.colorBy ? f.properties?.[st.colorBy] : null;
|
|
259
|
+
const cat = st.categories?.find((c) => c.value === String(v ?? "").trim());
|
|
260
|
+
return mapColorCSS(cat?.color || st.color || "c1");
|
|
261
|
+
};
|
|
262
|
+
for (const layer of model.layers) for (const f of layer.data.features) {
|
|
263
|
+
const g = f.geometry, col = color(layer, f);
|
|
264
|
+
const lines = (cs) => cs.map((l) => `M${l.map(P).join("L")}`).join("");
|
|
265
|
+
if (g.type === "Point") { const [x, y] = P(g.coordinates).split(","); out.push(`<circle cx="${x}" cy="${y}" r="8" fill="${col}" stroke="#fff" stroke-width="3"/>`); }
|
|
266
|
+
else if (g.type === "LineString") out.push(`<path d="${lines([g.coordinates])}" fill="none" stroke="${col}" stroke-width="5"/>`);
|
|
267
|
+
else if (g.type === "MultiLineString") out.push(`<path d="${lines(g.coordinates)}" fill="none" stroke="${col}" stroke-width="5"/>`);
|
|
268
|
+
else if (g.type === "Polygon") out.push(`<path d="${lines(g.coordinates)}Z" fill="${col}" fill-opacity=".35" stroke="${col}" stroke-width="2"/>`);
|
|
269
|
+
else if (g.type === "MultiPolygon") out.push(`<path d="${g.coordinates.map((p) => lines(p) + "Z").join("")}" fill="${col}" fill-opacity=".35" stroke="${col}" stroke-width="${f.properties?._faixa ? 0 : 2}"/>`);
|
|
270
|
+
}
|
|
271
|
+
return `<svg class="map-preview" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">${out.join("")}</svg>`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const mapSummary = (model) => model.layers.map((l) => `${l.name}: ${l.data.features.length}`).join(" · ");
|
package/src/mcp/server.js
CHANGED
|
@@ -129,6 +129,18 @@ const TOOLS = [
|
|
|
129
129
|
required: ["query"],
|
|
130
130
|
},
|
|
131
131
|
},
|
|
132
|
+
{
|
|
133
|
+
name: "sagadeck_ia_check",
|
|
134
|
+
description: "Testa a IA configurada nesta máquina, sem tela: texto, visão (o modelo precisa dizer as cores de uma imagem), geração de imagem, pesquisa na web e vídeo, cada um com uma chamada de verdade, em paralelo. Rode antes de usar recursos de IA. Status por recurso: ok, falhou, nao_configurado ou sem_prova (vídeo sem gerar). Vídeo só é gerado de verdade com video_real: true (é cobrado).",
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: "object",
|
|
137
|
+
properties: {
|
|
138
|
+
so: { type: "array", items: { type: "string", enum: ["texto", "visao", "imagem", "pesquisa", "video"] }, description: "Só estes recursos (padrão: todos)" },
|
|
139
|
+
rapido: { type: "boolean", description: "Pula geração de imagem e vídeo (os lentos e cobrados)" },
|
|
140
|
+
video_real: { type: "boolean", description: "Gera um clipe curto de verdade e confere o MP4 (cobrado; leva minutos)" },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
132
144
|
{
|
|
133
145
|
name: "sagadeck_launch_studio",
|
|
134
146
|
description: "Inicia o SagaDeck Studio (página web estilo PowerPoint com canvas visual, edição WYSIWYG e chat lateral com IA).",
|
|
@@ -221,6 +233,11 @@ export function runMCPServer() {
|
|
|
221
233
|
|
|
222
234
|
async function handleToolCall(name, args) {
|
|
223
235
|
switch (name) {
|
|
236
|
+
case "sagadeck_ia_check": {
|
|
237
|
+
const { iaCheck } = await import("../ai/ia-check.js");
|
|
238
|
+
return iaCheck({ only: args.so, rapido: !!args.rapido, videoReal: !!args.video_real });
|
|
239
|
+
}
|
|
240
|
+
|
|
224
241
|
case "sagadeck_read_deck": {
|
|
225
242
|
const spec = loadSpec(path.resolve(args.path));
|
|
226
243
|
return {
|