sagadeck 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +40 -0
  2. package/docs/ANDAMENTO.md +122 -0
  3. package/docs/REFERENCIA.md +58 -0
  4. package/package.json +2 -1
  5. package/src/ai/deck-ai.js +13 -4
  6. package/src/ai/map-analysis-run.js +119 -0
  7. package/src/ai/map-data.js +221 -0
  8. package/src/build.js +6 -2
  9. package/src/export/browser.js +23 -1
  10. package/src/export/pptx.js +2 -1
  11. package/src/export/shots.js +3 -1
  12. package/src/layouts.js +10 -0
  13. package/src/map-analysis.js +106 -0
  14. package/src/map-config.js +83 -0
  15. package/src/map-routing.js +82 -0
  16. package/src/map-services.js +60 -0
  17. package/src/map.js +261 -0
  18. package/src/runtime/base.css +20 -0
  19. package/src/runtime/map.js +234 -0
  20. package/src/runtime/runtime.js +1 -1
  21. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  22. package/src/runtime/vendor/geo-contornos.json +1 -0
  23. package/src/runtime/vendor/leaflet.css +661 -0
  24. package/src/runtime/vendor/leaflet.js +6 -0
  25. package/src/studio/ai-chat-route.js +20 -0
  26. package/src/studio/demo-decks.js +3 -1
  27. package/src/studio/geo-routes.js +138 -0
  28. package/src/studio/layout-samples.js +71 -0
  29. package/src/studio/project.js +1 -1
  30. package/src/studio/public/ai-settings.js +4 -1
  31. package/src/studio/public/app.js +7 -3
  32. package/src/studio/public/explorer.js +23 -8
  33. package/src/studio/public/index.html +4 -0
  34. package/src/studio/public/library.html +2 -0
  35. package/src/studio/public/library.js +6 -1
  36. package/src/studio/public/map-data.js +154 -0
  37. package/src/studio/public/map-editor.js +287 -0
  38. package/src/studio/public/map-fields.js +21 -0
  39. package/src/studio/public/map-settings.js +84 -0
  40. package/src/studio/public/slide-form.js +1 -1
  41. package/src/studio/public/style.css +13 -0
  42. package/src/studio/public/ui-icons.js +1 -1
  43. package/src/studio/public/viewers.js +2 -1
  44. package/src/studio/public/visual-editor.js +1 -1
  45. package/src/studio/server.js +10 -0
  46. package/src/studio/tour.js +76 -0
@@ -0,0 +1,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(" · ");
@@ -1775,3 +1775,23 @@ html[data-orient="portrait"] .adaptive:is([data-item-count="1"],[data-item-count
1775
1775
 
1776
1776
  /* Figuras informativas dominam a composição; texto auxilia a leitura. */
1777
1777
  .L-split.sp-stacked{flex-direction:column;gap:20px}.sp-stacked>.hd{flex:0 0 auto}.sp-stacked>.hd .ttl{font-size:64px}.sp-stacked>.sp-fig{flex:1;min-height:0}.sp-stacked>.sp-summary{flex:0 0 auto;display:flex;gap:32px}.sp-summary>.t,.sp-summary>.list{flex:1;margin:0}.sp-stacked .sp-fig>.fig{min-height:0}
1778
+ /* mapa (layout: map; src/map.js e src/runtime/map.js): prévia SVG por baixo, o Leaflet por cima quando monta */
1779
+ .L-map{flex:1 0 auto;display:flex;flex-direction:column;gap:24px}.L-map>.hd,.L-map>.map-caption{flex:none}
1780
+ .map-box{position:relative;flex:1;min-height:420px;border-radius:var(--radius);overflow:hidden;background:color-mix(in srgb,var(--fg) 5%,var(--surface));border:1px solid var(--line)}
1781
+ .map-preview,.map-live{position:absolute;inset:0;width:100%;height:100%}
1782
+ .map-box[data-map-mounted="ready"] .map-preview{display:none}
1783
+ .map-live{background:transparent;font:inherit}.map-live .leaflet-container{font:inherit}
1784
+ .map-box[data-basemap="claro"] .leaflet-tile-pane{filter:grayscale(1) contrast(.92) brightness(1.06)}
1785
+ .map-box[data-basemap="escuro"] .leaflet-tile-pane{filter:invert(1) hue-rotate(180deg) grayscale(.55) brightness(.92) contrast(.9)}
1786
+ .map-legend{position:absolute;left:18px;bottom:18px;z-index:500;display:flex;flex-direction:column;gap:10px;max-width:46%;max-height:70%;overflow:auto;padding:14px 18px;border-radius:calc(var(--radius) * .6);background:color-mix(in srgb,var(--bg) 90%,transparent);color:var(--fg);font-size:20px;box-shadow:0 6px 24px rgb(0 0 0/.14)}
1787
+ .map-legend button{all:unset;cursor:pointer;display:inline-flex;align-items:center;gap:8px;margin:2px 14px 2px 0}.map-legend button.off,.map-leg-layer.off .map-leg-name{opacity:.38;text-decoration:line-through}
1788
+ .map-leg-name{font-weight:700}.map-leg-name small{font-weight:400;color:var(--muted)}.map-legend i{display:inline-block;width:16px;height:16px;border-radius:50%;flex:none}
1789
+ .map-leg-scale{display:flex;align-items:center;gap:8px;font-size:17px;color:var(--muted)}.map-leg-scale i{width:120px;height:10px;border-radius:5px}
1790
+ .map-notice{position:absolute;top:16px;left:50%;transform:translateX(-50%);z-index:600;display:flex;align-items:center;gap:12px;padding:8px 14px;border-radius:999px;background:var(--fg);color:var(--bg);font-size:17px;white-space:nowrap}
1791
+ .map-notice button{all:unset;cursor:pointer;text-decoration:underline}
1792
+ .map-label{font:600 22px/1.2 var(--font-body,inherit);background:color-mix(in srgb,var(--bg) 92%,transparent);color:var(--fg);border:0;box-shadow:0 2px 8px rgb(0 0 0/.18);padding:3px 8px}
1793
+ .map-label::before{display:none}
1794
+ .map-card .leaflet-popup-content-wrapper{border-radius:calc(var(--radius) * .6);background:var(--bg);color:var(--fg)}.map-card .leaflet-popup-tip{background:var(--bg)}
1795
+ .map-card-in{font-size:18px;line-height:1.35}.map-card-in b{display:block;font-size:21px;margin-bottom:6px}.map-card-in table{border-collapse:collapse}.map-card-in th{text-align:left;font-weight:600;color:var(--muted);padding:2px 12px 2px 0}.map-card-in td{padding:2px 0}
1796
+ .map-box .leaflet-control-attribution{font-size:13px;background:color-mix(in srgb,var(--bg) 82%,transparent);color:var(--muted)}.map-box .leaflet-control-attribution a{color:inherit}
1797
+ .export .map-box .leaflet-control-zoom,.export .map-notice button{display:none}