sagadeck 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +41 -1
  2. package/SKILL.md +3 -3
  3. package/bin/sagadeck.js +2 -2
  4. package/docs/ANDAMENTO.md +162 -4
  5. package/docs/EXPERIENCIAS.md +1 -1
  6. package/docs/REFERENCIA.md +59 -1
  7. package/package.json +2 -1
  8. package/src/ai/deck-ai.js +13 -4
  9. package/src/ai/ia-config.js +17 -2
  10. package/src/ai/llm.js +6 -4
  11. package/src/ai/map-analysis-run.js +119 -0
  12. package/src/ai/map-data.js +221 -0
  13. package/src/ai/variety.js +1 -1
  14. package/src/build.js +6 -2
  15. package/src/experiences.js +1 -1
  16. package/src/export/browser.js +23 -1
  17. package/src/export/pptx.js +2 -1
  18. package/src/export/shots.js +3 -1
  19. package/src/layouts.js +10 -0
  20. package/src/library.js +2 -2
  21. package/src/map-analysis.js +106 -0
  22. package/src/map-config.js +83 -0
  23. package/src/map-routing.js +82 -0
  24. package/src/map-services.js +60 -0
  25. package/src/map.js +261 -0
  26. package/src/mcp/server.js +4 -4
  27. package/src/runtime/base.css +21 -1
  28. package/src/runtime/code-lab.js +10 -10
  29. package/src/runtime/map.js +234 -0
  30. package/src/runtime/runtime.js +1 -1
  31. package/src/runtime/skins/aluminio.css +1 -1
  32. package/src/runtime/skins/prata.css +1 -1
  33. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  34. package/src/runtime/vendor/geo-contornos.json +1 -0
  35. package/src/runtime/vendor/leaflet.css +661 -0
  36. package/src/runtime/vendor/leaflet.js +6 -0
  37. package/src/studio/ai-chat-route.js +20 -0
  38. package/src/studio/banner.js +1 -1
  39. package/src/studio/demo-decks.js +3 -1
  40. package/src/studio/geo-routes.js +138 -0
  41. package/src/studio/ia-routes.js +4 -1
  42. package/src/studio/layout-samples.js +71 -0
  43. package/src/studio/project.js +1 -1
  44. package/src/studio/public/ai-settings.js +15 -2
  45. package/src/studio/public/app.js +13 -7
  46. package/src/studio/public/chat.js +1 -1
  47. package/src/studio/public/explorer.js +23 -8
  48. package/src/studio/public/index.html +4 -0
  49. package/src/studio/public/library.css +2 -1
  50. package/src/studio/public/library.html +2 -0
  51. package/src/studio/public/library.js +10 -1
  52. package/src/studio/public/map-data.js +154 -0
  53. package/src/studio/public/map-editor.js +287 -0
  54. package/src/studio/public/map-fields.js +21 -0
  55. package/src/studio/public/map-settings.js +84 -0
  56. package/src/studio/public/slide-form.js +1 -1
  57. package/src/studio/public/style.css +13 -0
  58. package/src/studio/public/ui-icons.js +1 -1
  59. package/src/studio/public/viewers.js +2 -1
  60. package/src/studio/public/visual-editor.js +1 -1
  61. package/src/studio/server.js +13 -1
  62. package/src/studio/tour.js +76 -0
  63. package/src/templates/scaffold.js +5 -5
  64. package/src/themes.js +5 -5
  65. package/templates/{exemplo-keynote.yaml → exemplo-prata.yaml} +6 -6
@@ -0,0 +1,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
+ }
@@ -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,261 @@
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
+ if (obj.type === "FeatureCollection") return (obj.features || []).filter((f) => f && f.geometry);
114
+ if (obj.type === "Feature") return obj.geometry ? [obj] : [];
115
+ if (obj.type && obj.coordinates) return [{ type: "Feature", properties: {}, geometry: obj }];
116
+ return [];
117
+ }
118
+
119
+ // valores por estado (sigla) ou país (código de 2 letras): o contorno embutido, pintado pelo valor
120
+ function fromAreas(values, warn) {
121
+ const g = geoContornos();
122
+ const features = [];
123
+ for (const [k, v] of Object.entries(values || {})) {
124
+ const code = String(k).trim().toUpperCase();
125
+ const uf = g.uf[code], pais = g.paises[code];
126
+ if (!uf && !pais) { warn(`mapa: "${k}" não é uma UF (SP, RJ…) nem um país (BR, PT…)`); continue; }
127
+ const poly = uf || pais.poly;
128
+ features.push({ type: "Feature", properties: { nome: uf ? code : pais.nome, valor: v }, geometry: { type: "MultiPolygon", coordinates: poly } });
129
+ }
130
+ return features;
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------------------------- estilo das camadas
134
+ // cor: uma coluna (categoria -> cores da paleta; número -> escala da cor de destaque), uma cor fixa, ou nada
135
+ function styleOf(features, layer, warn) {
136
+ const style = { color: null, colorBy: null, categories: null, scale: null, sizeBy: null, sizeScale: null, label: layer.label || null, popup: null, icon: layer.icon || null };
137
+ const props = features.map((f) => f.properties || {});
138
+ const has = (col) => col && props.some((p) => Object.hasOwn(p, col));
139
+ const by = layer.color ?? (layer.areas ? "valor" : null);
140
+ if (by && !has(by)) {
141
+ if (/^(#?[0-9a-f]{6}|s[1-5]|c\d|em|hi|alert|accent|fg|muted)$/i.test(String(by))) style.color = String(by);
142
+ else warn(`mapa: a camada "${layer.name || ""}" pinta pela coluna "${by}", que não existe`);
143
+ } else if (by) {
144
+ style.colorBy = by;
145
+ const vals = props.map((p) => p[by]);
146
+ const nums = vals.map(toNum).filter((n) => n != null);
147
+ if (nums.length === vals.filter((v) => String(v ?? "").trim() !== "").length && nums.length && !layer.colors) {
148
+ style.scale = { min: Math.min(...nums), max: Math.max(...nums) };
149
+ } else {
150
+ const order = [...new Set(vals.map((v) => String(v ?? "").trim()).filter(Boolean))];
151
+ const given = layer.colors && typeof layer.colors === "object" ? layer.colors : {};
152
+ 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:)`);
153
+ style.categories = order.map((v, i) => ({ value: v, color: String(given[v] ?? MAP_COLORS[i % MAP_COLORS.length]) }));
154
+ }
155
+ }
156
+ if (layer.size) {
157
+ if (!has(layer.size)) warn(`mapa: a camada "${layer.name || ""}" usa a coluna "${layer.size}" para o tamanho, que não existe`);
158
+ else {
159
+ const nums = props.map((p) => toNum(p[layer.size])).filter((n) => n != null);
160
+ if (nums.length) { style.sizeBy = layer.size; style.sizeScale = { min: Math.min(...nums), max: Math.max(...nums) }; }
161
+ }
162
+ }
163
+ if (style.label && !has(style.label)) { warn(`mapa: a coluna "${style.label}" do rótulo não existe`); style.label = null; }
164
+ const cols = [...new Set(props.flatMap((p) => Object.keys(p)))].filter((c) => !c.startsWith("_"));
165
+ style.popup = Array.isArray(layer.popup) ? layer.popup.filter((c) => cols.includes(c)) : layer.popup === false ? [] : cols.slice(0, 8);
166
+ return style;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------------------------- o slide inteiro
170
+ function bboxOf(layers) {
171
+ let s = 90, w = 180, n = -90, e = -180, any = false;
172
+ const visit = (c) => {
173
+ 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; }
174
+ c.forEach(visit);
175
+ };
176
+ for (const l of layers) for (const f of l.data.features) if (f.geometry?.coordinates) visit(f.geometry.coordinates);
177
+ return any ? [[s, w], [n, e]] : null;
178
+ }
179
+
180
+ const viewOf = (v) => {
181
+ if (v === "fit" || v === true) return "fit";
182
+ 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 };
183
+ return null;
184
+ };
185
+
186
+ export function mapModel(s, ctx) {
187
+ const warn = (m) => ctx?.warnings?.push(m);
188
+ const list = Array.isArray(s.layers) ? s.layers : s.layers ? [s.layers] : [];
189
+ const layers = [];
190
+ list.forEach((layer, i) => {
191
+ if (!layer || typeof layer !== "object") return;
192
+ let features = [], kind = "shapes";
193
+ if (layer.points != null) {
194
+ kind = "points";
195
+ if (typeof layer.points === "string") {
196
+ const text = readText(layer.points, ctx, "planilha");
197
+ if (text != null) {
198
+ // sem tirar as linhas vazias: _linha é o número da linha na planilha (a seleção liga linha e ponto)
199
+ const { rows } = parseCSV(text);
200
+ features = pointsFromRows((rows[0] || []).map((h) => String(h).trim()), rows.slice(1), layer, warn).features;
201
+ }
202
+ } else if (Array.isArray(layer.points)) {
203
+ const rows = layer.points.filter((p) => p && typeof p === "object");
204
+ const head = [...new Set(rows.flatMap((p) => Object.keys(p)))];
205
+ features = pointsFromRows(head, rows.map((p) => head.map((h) => p[h] ?? "")), layer, warn).features;
206
+ }
207
+ } else if (layer.geojson) {
208
+ const text = readText(layer.geojson, ctx, "GeoJSON");
209
+ try { features = text == null ? [] : fromGeoJSON(JSON.parse(text)); } catch { warn(`mapa: "${layer.geojson}" não é um GeoJSON válido`); }
210
+ } else if (layer.gpx) {
211
+ const text = readText(layer.gpx, ctx, "GPX"); features = text == null ? [] : fromGPX(text);
212
+ } else if (layer.kml) {
213
+ const text = readText(layer.kml, ctx, "KML"); features = text == null ? [] : fromKML(text);
214
+ } else if (layer.areas) {
215
+ kind = "areas"; features = fromAreas(layer.areas, warn);
216
+ } else { warn(`mapa: a camada ${i + 1} não diz de onde vêm os dados (points, geojson, gpx, kml ou areas)`); return; }
217
+ if (!features.length && layer.areas == null) warn(`mapa: a camada "${layer.name || i + 1}" ficou vazia`);
218
+ const step = layer.step ?? (s.build ? i + 1 : undefined);
219
+ layers.push({
220
+ name: layer.name || layer.title || `Camada ${i + 1}`, kind,
221
+ source: typeof layer.points === "string" ? layer.points.trim() : layer.geojson || layer.gpx || layer.kml || null, step: Number.isFinite(Number(step)) ? Number(step) : undefined,
222
+ view: viewOf(layer.view), legend: layer.legend || null, prefix: layer.prefix || "", suffix: layer.suffix || "",
223
+ style: styleOf(features, layer, warn), data: { type: "FeatureCollection", features },
224
+ });
225
+ });
226
+ const basemap = BASEMAPS.includes(s.basemap) ? s.basemap : "ruas";
227
+ if (s.basemap && !BASEMAPS.includes(s.basemap)) warn(`mapa: fundo "${s.basemap}" não existe (use ${BASEMAPS.join(", ")})`);
228
+ return { basemap, view: viewOf(s.view), bounds: bboxOf(layers), layers, tiles: ctx?.mapTiles || null };
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------------------------- prévia sem fundo
232
+ // SVG das camadas em Web Mercator, enquadradas: miniaturas do Studio (sem pedir tile nenhum) e reserva se o mapa ao
233
+ // vivo não montar
234
+ 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)]; };
235
+ export function mapPreviewSVG(model, w = 1600, h = 760) {
236
+ if (!model.bounds) return `<svg class="map-preview" viewBox="0 0 ${w} ${h}" aria-hidden="true"></svg>`;
237
+ const [[s, wst], [n, e]] = model.bounds;
238
+ const [x0, y1] = merc([wst, s]), [x1, y0] = merc([e, n]);
239
+ const pad = 0.08, bw = Math.max(x1 - x0, 1e-6), bh = Math.max(y1 - y0, 1e-6);
240
+ const k = Math.min(w / (bw * (1 + 2 * pad)), h / (bh * (1 + 2 * pad)));
241
+ const ox = w / 2 - ((x0 + x1) / 2) * k, oy = h / 2 - ((y0 + y1) / 2) * k;
242
+ const P = (c) => { const [x, y] = merc(c); return `${(x * k + ox).toFixed(1)},${(y * k + oy).toFixed(1)}`; };
243
+ const out = [];
244
+ const color = (layer, f) => {
245
+ const st = layer.style, v = st.colorBy ? f.properties?.[st.colorBy] : null;
246
+ const cat = st.categories?.find((c) => c.value === String(v ?? "").trim());
247
+ return mapColorCSS(cat?.color || st.color || "c1");
248
+ };
249
+ for (const layer of model.layers) for (const f of layer.data.features) {
250
+ const g = f.geometry, col = color(layer, f);
251
+ const lines = (cs) => cs.map((l) => `M${l.map(P).join("L")}`).join("");
252
+ if (g.type === "Point") { const [x, y] = P(g.coordinates).split(","); out.push(`<circle cx="${x}" cy="${y}" r="9" fill="${col}" stroke="var(--bg)" stroke-width="3"/>`); }
253
+ else if (g.type === "LineString") out.push(`<path d="${lines([g.coordinates])}" fill="none" stroke="${col}" stroke-width="5"/>`);
254
+ else if (g.type === "MultiLineString") out.push(`<path d="${lines(g.coordinates)}" fill="none" stroke="${col}" stroke-width="5"/>`);
255
+ else if (g.type === "Polygon") out.push(`<path d="${lines(g.coordinates)}Z" fill="${col}" fill-opacity=".35" stroke="${col}" stroke-width="2"/>`);
256
+ 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="2"/>`);
257
+ }
258
+ return `<svg class="map-preview" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">${out.join("")}</svg>`;
259
+ }
260
+
261
+ export const mapSummary = (model) => model.layers.map((l) => `${l.name}: ${l.data.features.length}`).join(" · ");
package/src/mcp/server.js CHANGED
@@ -91,8 +91,8 @@ const TOOLS = [
91
91
  },
92
92
  type: {
93
93
  type: "string",
94
- enum: ["keynote", "pitch", "palestra"],
95
- description: "Tipo narrativo do esqueleto (padrão: keynote)",
94
+ enum: ["lancamento", "pitch", "palestra"],
95
+ description: "Tipo narrativo do esqueleto (padrão: lancamento)",
96
96
  },
97
97
  author: { type: "string", description: "Nome do autor ou palestrante" },
98
98
  },
@@ -110,7 +110,7 @@ const TOOLS = [
110
110
  theme: {
111
111
  type: "string",
112
112
  enum: ["sinal", "prata", "rabisco", "oceano", "pop", "aurora", "editorial", "noite", "bauhaus", "terminal", "jornal"],
113
- description: "Tema visual do Sagadeck (ex.: prata para Apple Keynote, rabisco para artesanal, oceano para azul elétrico)",
113
+ description: "Tema visual do Sagadeck (ex.: prata para limpo e espaçoso, rabisco para artesanal, oceano para azul elétrico)",
114
114
  },
115
115
  author: { type: "string", description: "Nome do autor ou palestrante" },
116
116
  },
@@ -307,7 +307,7 @@ async function handleToolCall(name, args) {
307
307
  const spec = generateScaffold({
308
308
  title: args.title || "Nova Apresentação",
309
309
  theme: args.theme || "prata",
310
- type: args.type || "keynote",
310
+ type: args.type || "lancamento",
311
311
  author: args.author || "Seu Nome",
312
312
  });
313
313
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });