sagadeck 1.8.0 → 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.
Files changed (92) hide show
  1. package/README.md +19 -8
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +367 -7
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +131 -110
  7. package/package.json +33 -2
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/deck-ai.js +1 -1
  10. package/src/ai/ia-check.js +127 -0
  11. package/src/ai/ia-config.js +2 -2
  12. package/src/ai/llm.js +5 -1
  13. package/src/ai/map-analysis-run.js +1 -1
  14. package/src/ai/quality.js +1 -1
  15. package/src/ai/video-generation.js +70 -25
  16. package/src/build.js +46 -14
  17. package/src/elements.js +39 -1
  18. package/src/export/pptx.js +7 -2
  19. package/src/export/shots.js +7 -2
  20. package/src/fiscal/normalize.js +19 -0
  21. package/src/fs-retry.js +37 -2
  22. package/src/import/office.js +5 -4
  23. package/src/layouts.js +104 -99
  24. package/src/lessons.js +1 -1
  25. package/src/library.js +7 -7
  26. package/src/map-analysis.js +4 -1
  27. package/src/map-config.js +10 -4
  28. package/src/map.js +21 -8
  29. package/src/mcp/server.js +17 -0
  30. package/src/motion.js +20 -6
  31. package/src/purpose.js +1 -1
  32. package/src/runtime/base.css +88 -332
  33. package/src/runtime/fit.js +3 -2
  34. package/src/runtime/fonts/amatic-sc.css +3 -0
  35. package/src/runtime/fonts/bebas-neue.css +2 -0
  36. package/src/runtime/fonts/cabin-sketch.css +2 -0
  37. package/src/runtime/fonts/fraunces.css +3 -0
  38. package/src/runtime/fonts/gelasio.css +3 -0
  39. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  40. package/src/runtime/fonts/index.json +10 -1
  41. package/src/runtime/fonts/itim.css +2 -0
  42. package/src/runtime/fonts/montserrat.css +2 -0
  43. package/src/runtime/fonts/space-grotesk.css +2 -0
  44. package/src/runtime/map-background.js +91 -0
  45. package/src/runtime/map.js +41 -9
  46. package/src/runtime/runtime.js +81 -100
  47. package/src/runtime/skins/caderno.css +33 -0
  48. package/src/runtime/skins/cartaz.css +27 -0
  49. package/src/runtime/skins/infografico.css +29 -0
  50. package/src/runtime/skins/lousa.css +22 -0
  51. package/src/runtime/skins/musgo.css +42 -0
  52. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  53. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  54. package/src/runtime/vendor/geo-contornos.json +1 -1
  55. package/src/runtime/vendor/maplibre-gl.css +1 -0
  56. package/src/runtime/vendor/maplibre-gl.js +825 -0
  57. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  58. package/src/runtime/vendor/maplibre-worker.js +10 -0
  59. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  60. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  61. package/src/studio/command-approval.js +2 -3
  62. package/src/studio/demo-decks.js +4 -2
  63. package/src/studio/export-jobs.js +86 -0
  64. package/src/studio/exporting.js +36 -18
  65. package/src/studio/feature-demos.js +144 -0
  66. package/src/studio/ia-routes.js +9 -0
  67. package/src/studio/layout-samples.js +9 -18
  68. package/src/studio/library-routes.js +0 -9
  69. package/src/studio/project.js +1 -0
  70. package/src/studio/public/ai-settings.js +2 -2
  71. package/src/studio/public/app.js +41 -51
  72. package/src/studio/public/export-status.js +154 -0
  73. package/src/studio/public/index.html +13 -10
  74. package/src/studio/public/library.css +12 -3
  75. package/src/studio/public/library.html +1 -0
  76. package/src/studio/public/library.js +36 -14
  77. package/src/studio/public/map-data.js +7 -3
  78. package/src/studio/public/map-editor.js +625 -236
  79. package/src/studio/public/map-settings.js +3 -1
  80. package/src/studio/public/slide-form.js +18 -15
  81. package/src/studio/public/style.css +54 -9
  82. package/src/studio/public/ui-icons.js +1 -1
  83. package/src/studio/server.js +27 -12
  84. package/src/studio/snapshot.js +8 -4
  85. package/src/studio/template-collections.js +1 -1
  86. package/src/studio/tour.js +89 -7
  87. package/src/themes.js +154 -0
  88. package/templates/cenario-e-ciencia.yaml +1 -21
  89. package/templates/ensaio-api.yaml +3 -3
  90. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  91. package/templates/cenario/imagens/cidade.svg +0 -30
  92. package/templates/cenario/imagens/pessoa.svg +0 -15
@@ -1,287 +1,676 @@
1
- // Editor do slide de mapa no Studio (carregado só quando alguém clica em "Editar no mapa", por map-data.js).
2
- // Ferramentas: Mover (arrastar pontos e vértices; dividir e juntar linhas), Ponto (clique cria uma linha na planilha da
3
- // camada, com o endereço preenchido), Linha e Área (desenho; "Seguir as ruas" liga os cliques pelo serviço de rotas),
4
- // Régua e "Usar esta vista". Pontos vão para o CSV da camada; linhas e áreas, para o GeoJSON da camada. Comprimento e
5
- // área aparecem enquanto desenha. Nada aqui inventa coordenada: vem do clique, do serviço de rotas ou do arquivo.
1
+ // Editor do mapa no Studio: uma tela própria, por cima do Studio (pedido de Narumi: "não consigo clicar pra editar, não
2
+ // sei como edita, não sei se tem uma tela diferente"). Carregado só no primeiro "Editar mapa" (map-data.js).
3
+ // - esquerda: as camadas (tipo, nome, quantos itens; o olho esconde só aqui) e "Nova camada";
4
+ // - meio: o mapa grande, sem a escala da prévia (clicar acerta o lugar), com as ferramentas Selecionar, Ponto, Linha,
5
+ // Área e Régua e, embaixo, a dica do que fazer agora;
6
+ // - direita: as propriedades do que está selecionado (ponto, linha, área, região: os campos, com o que cada um faz no
7
+ // mapa) ou, sem seleção, da camada (cor, tamanho, rótulo, cartão, quando aparece) e do mapa (fundo, enquadramento).
8
+ // Edita o que está escrito no slide (pontos e formas) e o que está em arquivo do projeto (planilha CSV, GeoJSON); GPX e
9
+ // KML só se leem. Modelo em prévia: a primeira gravação cria a cópia (o servidor faz, o Studio avisa).
10
+ // Nada aqui inventa coordenada: vem do clique, do serviço de rotas ou do arquivo.
6
11
  window.SagaMapEditor = (() => {
7
12
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
8
13
  const norm = (s) => String(s ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
9
- const LAT = ["lat", "latitude"], LON = ["lon", "lng", "long", "longitude"], ADDR = ["endereco", "address", "logradouro", "rua"];
14
+ const LAT = ["lat", "latitude"], LON = ["lon", "lng", "long", "longitude"], ADDR = ["endereco", "address", "logradouro", "rua", "enderecocompleto"];
15
+ const isLat = (h) => LAT.includes(norm(h)), isLon = (h) => LON.includes(norm(h));
10
16
  const post = async (url, body) => { const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const j = await r.json().catch(() => ({})); if (!r.ok || j.error) throw new Error(j.error || `HTTP ${r.status}`); return j; };
11
17
  const getJSON = async (url) => { const r = await fetch(url); const j = await r.json().catch(() => ({})); if (!r.ok || j.error) throw new Error(j.error || `HTTP ${r.status}`); return j; };
18
+ const ic = (n) => `<i class="ic" data-ic="${n}"></i>`;
19
+ const plain = (s) => String(s ?? "").replace(/==|\*\*|__/g, "").replace(/\s+/g, " ").trim();
12
20
  const fmtLen = (m) => (m >= 1000 ? `${(m / 1000).toLocaleString("pt-BR", { maximumFractionDigits: 2 })} km` : `${Math.round(m)} m`);
13
21
  const fmtArea = (a) => (a >= 1e6 ? `${(a / 1e6).toLocaleString("pt-BR", { maximumFractionDigits: 2 })} km²` : a >= 1e4 ? `${(a / 1e4).toLocaleString("pt-BR", { maximumFractionDigits: 2 })} ha` : `${Math.round(a)} m²`);
14
- // área de um anel [lat, lon] na esfera (m²)
15
- const ringArea = (pts) => { const R = 6371008.8, rad = Math.PI / 180; let s = 0; for (let i = 0; i < pts.length; i++) { const [la1, lo1] = pts[i], [la2, lo2] = pts[(i + 1) % pts.length]; s += (lo2 - lo1) * rad * (2 + Math.sin(la1 * rad) + Math.sin(la2 * rad)); } return Math.abs((s * R * R) / 2); };
22
+ const R = 6371008.8, rad = Math.PI / 180;
23
+ // área de um anel [lat, lon] na esfera (m²) e comprimento de uma linha [lon, lat] (m)
24
+ const ringArea = (pts) => { let s = 0; for (let i = 0; i < pts.length; i++) { const [la1, lo1] = pts[i], [la2, lo2] = pts[(i + 1) % pts.length]; s += (lo2 - lo1) * rad * (2 + Math.sin(la1 * rad) + Math.sin(la2 * rad)); } return Math.abs((s * R * R) / 2); };
25
+ const lineLen = (c) => c.reduce((s, p, i) => { if (!i) return 0; const [lo1, la1] = c[i - 1], [lo2, la2] = p; const a = Math.sin(((la2 - la1) * rad) / 2) ** 2 + Math.cos(la1 * rad) * Math.cos(la2 * rad) * Math.sin(((lo2 - lo1) * rad) / 2) ** 2; return s + 2 * R * Math.asin(Math.sqrt(a)); }, 0);
26
+ const asValue = (v) => (/^-?\d+([.,]\d+)?$/.test(String(v).trim()) ? Number(String(v).trim().replace(",", ".")) : v);
27
+ // cores do tema que o mapa usa (src/map.js, MAP_COLORS): vêm do slide para a tela do editor
28
+ const THEME_VARS = ["bg", "fg", "muted", "line", "surface", "hi", "em", "on-hi", "alert", "c-c1", "c-c2", "c-c3", "c-c4", "c-c5", "c-accent", "c-alert", "c-ink", "c-paper"];
29
+ const SWATCHES = [["c1", "Cor 1"], ["c2", "Cor 2"], ["c3", "Cor 3"], ["c4", "Cor 4"], ["c5", "Cor 5"], ["em", "Destaque"], ["hi", "Realce"], ["alert", "Alerta"]];
30
+ const KINDS = { pontos: ["map-pin", "Pontos", "ponto", "pontos"], formas: ["spline", "Linhas e áreas", "forma", "formas"], regioes: ["map", "Regiões por valor", "região", "regiões"], arquivo: ["route", "Trajeto", "forma", "formas"] };
31
+ const kindOf = (l) => (l?.points != null ? "pontos" : l?.areas != null ? "regioes" : l?.geojson != null ? "formas" : l?.gpx || l?.kml ? "arquivo" : "");
32
+ const srcText = (l) => typeof l.points === "string" ? `Planilha ${l.points}` : Array.isArray(l.points) ? "Escritos no próprio slide" : typeof l.geojson === "string" ? `Arquivo ${l.geojson}` : l.geojson ? "Desenhadas no próprio slide" : l.areas ? "Valores no próprio slide" : l.gpx ? `Arquivo GPX ${l.gpx}: só se lê aqui` : l.kml ? `Arquivo KML ${l.kml}: só se lê aqui` : "";
33
+ const editable = (l) => ["pontos", "formas", "regioes"].includes(kindOf(l));
34
+ const TIPS = {
35
+ selecionar: "Clique num ponto, linha, área ou região para ver e mudar o que ele tem. Arraste um ponto para mudar o lugar.",
36
+ ponto: "Clique no mapa onde fica o ponto.",
37
+ linha: "Clique em cada ponto do caminho. Duplo clique ou Enter termina; Esc desfaz.",
38
+ area: "Clique nos cantos da área. Duplo clique ou Enter fecha; Esc desfaz.",
39
+ regua: "Clique para medir uma distância. Esc zera.",
40
+ };
16
41
 
17
- let S = null; // estado do editor: { box, slide, commit, ctx, map, mode, … }
42
+ let S = null; // { root, mbox, map, entries, box, slide, commit, ctx, mode, sel, layer, hidden, draft, … }
18
43
 
19
- function bar() {
20
- const b = document.createElement("div");
21
- b.className = "me-bar";
22
- const L = S.slide.layers || [];
23
- const pointLayers = L.map((l, i) => [l, i]).filter(([l]) => typeof l.points === "string");
24
- const shapeLayers = L.map((l, i) => [l, i]).filter(([l]) => typeof l.geojson === "string");
25
- b.innerHTML = `<div class="me-tools">${[["mover", "Mover"], ["ponto", "Ponto"], ["linha", "Linha"], ["area", "Área"], ["regua", "Régua"]].map(([m, t]) => `<button type="button" data-mode="${m}" class="${S.mode === m ? "on" : ""}">${t}</button>`).join("")}
26
- <label class="me-ruas" title="Liga os cliques da linha pelas ruas (serviço de rotas)"><input type="checkbox" data-ruas ${S.ruas ? "checked" : ""}> Seguir as ruas</label>
27
- <select data-modo title="Como andar"><option value="pe">a pé</option><option value="carro">de carro</option><option value="bicicleta">de bicicleta</option></select>
28
- <button type="button" data-vista title="Grava o enquadramento atual no slide">Usar esta vista</button>
29
- <button type="button" data-fim class="me-done">Concluir</button></div>
30
- <div class="me-row"><label>Pontos vão para <select data-alvo-ponto>${pointLayers.map(([l, i]) => `<option value="${i}">${esc(l.name || l.points)}</option>`).join("")}<option value="novo">nova camada (contexto/pontos.csv)</option></select></label>
31
- <label>Linhas e áreas vão para <select data-alvo-forma>${shapeLayers.map(([l, i]) => `<option value="${i}">${esc(l.name || l.geojson)}</option>`).join("")}<option value="novo">nova camada (contexto/desenhos.geojson)</option></select></label>
32
- <span class="me-status" data-status></span></div>`;
33
- b.querySelector("[data-modo]").value = S.modo;
34
- b.querySelectorAll("[data-mode]").forEach((x) => (x.onclick = () => setMode(x.dataset.mode)));
35
- b.querySelector("[data-ruas]").onchange = (e) => { S.ruas = e.target.checked; };
36
- b.querySelector("[data-modo]").onchange = (e) => { S.modo = e.target.value; };
37
- b.querySelector("[data-vista]").onclick = useView;
38
- b.querySelector("[data-fim]").onclick = () => { if (S.draft) finishShape(); else stop(); };
39
- for (const ev of ["pointerdown", "mousedown", "click", "dblclick", "wheel", "keydown"]) b.addEventListener(ev, (e) => e.stopPropagation());
40
- return b;
41
- }
42
- const status = (t) => { const s = S?.host.querySelector(".me-bar [data-status]"); if (s) s.textContent = t || ""; };
44
+ // ---------------------------------------------------------------- a tela
45
+ function open(box, slide, commit, ctx) {
46
+ if (S) close();
47
+ S = { box, slide, commit, ctx, key: slide.uid || slide.title, mode: "selecionar", sel: null, layer: 0, hidden: new Set(), ruas: true, modo: "pe", draft: null, handles: [] };
48
+ const first = (slide.layers || []).findIndex(editable);
49
+ S.layer = first >= 0 ? first : 0;
50
+ const root = document.createElement("div");
51
+ root.className = "mpe"; root.tabIndex = -1;
52
+ root.setAttribute("role", "dialog"); root.setAttribute("aria-label", "Editar mapa");
53
+ root.innerHTML = `<header class="mpe-top"><span class="mpe-title">${ic("map")} Editar mapa</span><span class="mpe-sub" data-sub></span><span class="mpe-state" data-state></span>
54
+ <button type="button" class="btn btn-primary" data-fim>${ic("check")} Concluir</button></header>
55
+ <aside class="mpe-side mpe-left"><div class="mpe-h">Camadas</div><ul class="mpe-layers" data-layers></ul>
56
+ <button type="button" class="btn mpe-new" data-nova>${ic("plus")} Nova camada</button><p class="mpe-note">A última da lista fica por cima no mapa.</p></aside>
57
+ <main class="mpe-stage"><div class="mpe-tools" role="toolbar" aria-label="Ferramentas do mapa">${[["selecionar", "mouse-pointer-2", "Selecionar"], ["ponto", "map-pin", "Ponto"], ["linha", "spline", "Linha"], ["area", "pentagon", "Área"], ["regua", "ruler", "Régua"]].map(([m, i, t]) => `<button type="button" data-mode="${m}" title="${t}">${ic(i)}<span>${t}</span></button>`).join("")}</div>
58
+ <div class="mpe-mapwrap" data-mapwrap></div>
59
+ <div class="mpe-tip"><span data-tip></span><span class="mpe-route" data-route hidden><label><input type="checkbox" data-ruas checked> Seguir as ruas</label><select data-modo aria-label="Como andar"><option value="pe">a pé</option><option value="carro">de carro</option><option value="bicicleta">de bicicleta</option></select></span>
60
+ <span class="mpe-draft" data-draftbtns hidden><button type="button" class="btn btn-small btn-primary" data-terminar>Terminar</button><button type="button" class="btn btn-small" data-cancelar>Desfazer</button></span></div></main>
61
+ <aside class="mpe-side mpe-right" data-props></aside>`;
62
+ document.body.append(root);
63
+ S.root = root;
64
+ // atalhos do Studio (setas trocam de slide, Delete apaga o slide…) não valem aqui dentro
65
+ root.addEventListener("keydown", onKey);
66
+ root.querySelector("[data-fim]").onclick = close;
67
+ root.querySelector("[data-nova]").onclick = newLayerMenu;
68
+ root.querySelectorAll("[data-mode]").forEach((b) => (b.onclick = () => setMode(b.dataset.mode)));
69
+ root.querySelector("[data-ruas]").onchange = (e) => { S.ruas = e.target.checked; };
70
+ root.querySelector("[data-modo]").onchange = (e) => { S.modo = e.target.value; };
71
+ root.querySelector("[data-terminar]").onclick = () => finishShape();
72
+ root.querySelector("[data-cancelar]").onclick = () => { clearDraft(); tipFor(); };
73
+ root.querySelector("[data-layers]").onclick = onLayersClick;
74
+ const props = root.querySelector("[data-props]");
75
+ props.addEventListener("change", onPropsChange);
76
+ props.addEventListener("click", onPropsClick);
77
+ S.ctx.hydrate?.(root);
78
+ root.focus();
79
+ mountMap();
80
+ renderAll();
81
+ setMode("selecionar");
82
+ }
83
+ function close() {
84
+ if (!S) return;
85
+ clearDraft(); clearHandles();
86
+ if (S.mbox) window.SagaMap.dispose(S.mbox);
87
+ S.root.remove();
88
+ const box = S.box; S = null;
89
+ window.SagaMapData?.editorStopped?.(box);
90
+ }
91
+ // o slide redesenhou (gravou algo): a tela continua, com os dados novos e a mesma vista
92
+ function resume(box, slide, commit, ctx) {
93
+ if (!S || (slide.uid || slide.title) !== S.key) return;
94
+ Object.assign(S, { box, slide, commit, ctx });
95
+ mountMap();
96
+ renderAll();
97
+ state("Gravado");
98
+ }
99
+
100
+ function renderAll() {
101
+ S.root.querySelector("[data-sub]").textContent = plain(S.slide.title) || "Mapa";
102
+ renderLayers();
103
+ renderProps();
104
+ tipFor();
105
+ }
106
+ const state = (t, bad = false) => { const s = S?.root.querySelector("[data-state]"); if (s) { s.textContent = t || ""; s.classList.toggle("bad", !!bad); } };
107
+ const tip = (t) => { const s = S?.root.querySelector("[data-tip]"); if (s) s.textContent = t; };
108
+ function tipFor() {
109
+ if (!S) return;
110
+ let t = TIPS[S.mode];
111
+ const l = S.slide.layers?.[S.layer];
112
+ if (S.mode === "ponto") t += kindOf(l) === "pontos" ? ` Ele entra em "${l.name || "Pontos"}".` : " Ele entra numa camada nova de pontos.";
113
+ if (S.mode === "linha" || S.mode === "area") t += kindOf(l) === "formas" ? ` Vai para "${l.name || "Desenhos"}".` : " Vai para uma camada nova de desenhos.";
114
+ if (S.joinFrom) t = "Clique na outra linha para juntar as duas. Esc desiste.";
115
+ tip(t);
116
+ S.root.querySelector("[data-route]").hidden = S.mode !== "linha";
117
+ S.root.querySelector("[data-draftbtns]").hidden = !S.draft || S.mode === "regua";
118
+ }
119
+
120
+ // ---------------------------------------------------------------- o mapa grande (o mesmo motor da apresentação)
121
+ function mountMap() {
122
+ const keep = S.map ? { c: S.map.getCenter(), z: S.map.getZoom() } : null;
123
+ clearHandles();
124
+ if (S.mbox) window.SagaMap.dispose(S.mbox);
125
+ const wrap = S.root.querySelector("[data-mapwrap]");
126
+ const box = document.createElement("div");
127
+ box.className = "map-box mpe-box";
128
+ box.dataset.basemap = S.box.dataset.basemap || "ruas"; // o filtro do fundo "claro"/"escuro" segue o slide
129
+ const data = document.createElement("script");
130
+ data.type = "application/json"; data.className = "map-data";
131
+ data.textContent = S.box.querySelector(".map-data")?.textContent || '{"layers":[]}';
132
+ const live = document.createElement("div"); live.className = "map-live";
133
+ box.append(data, live);
134
+ const cs = getComputedStyle(S.box);
135
+ for (const v of THEME_VARS) { const val = cs.getPropertyValue(`--${v}`); if (val) box.style.setProperty(`--${v}`, val); }
136
+ wrap.replaceChildren(box);
137
+ S.mbox = box; S.map = null; S.entries = [];
138
+ window.SagaMap.mount(box).then(() => {
139
+ if (!S || S.mbox !== box) return;
140
+ const inst = window.SagaMap.instance(box);
141
+ if (!inst?.map) return;
142
+ S.map = inst.map; S.entries = inst.layers;
143
+ if (keep) S.map.setView(keep.c, keep.z, { animate: false });
144
+ wire();
145
+ renderLayers();
146
+ if (S.pendingSel) { const p = S.pendingSel; S.pendingSel = null; select(p.li, p.id, { focus: p.focus }); }
147
+ else if (S.sel) select(S.sel.li, S.sel.id);
148
+ box.dataset.mpeReady = "1";
149
+ });
150
+ }
151
+ const idOf = (e, f) => { const p = f?.properties || {}; return e.layer.kind === "points" ? p._linha : e.layer.kind === "areas" ? p._cod : p._i; };
152
+ function wire() {
153
+ const map = S.map;
154
+ map.on("click", onMapClick);
155
+ map.on("dblclick", (ev) => { if (S.mode === "linha" || S.mode === "area") { window.L.DomEvent.stop(ev); finishShape(); } });
156
+ map.doubleClickZoom.disable();
157
+ for (const e of S.entries) {
158
+ e.on = true; if (e.legend) e.legend.hidden = false;
159
+ if (S.hidden.has(e.layer.index)) map.removeLayer(e.g); else if (!map.hasLayer(e.g)) e.g.addTo(map);
160
+ e.g.eachLayer((lyr) => {
161
+ lyr.off("click"); lyr.unbindPopup?.();
162
+ lyr._mpe = { color: lyr.options.color, weight: lyr.options.weight };
163
+ const li = e.layer.index, id = idOf(e, lyr.feature);
164
+ lyr.on("click", (ev) => {
165
+ window.L.DomEvent.stop(ev);
166
+ if (S.noClick) return;
167
+ if (S.joinFrom) return joinWith(li, id);
168
+ if (S.mode === "selecionar") select(li, id); else onMapClick(ev);
169
+ });
170
+ if (lyr.getLatLng && kindOf(S.slide.layers[li]) === "pontos") lyr.on("mousedown", (ev) => { if (S.mode === "selecionar") dragPoint(lyr, li, id, ev); });
171
+ });
172
+ }
173
+ window.SagaMap.instance(S.mbox)?.pointsOnTop?.(); // pontos por cima de áreas e raios: clicar no ponto pega o ponto
174
+ }
175
+ function highlight() {
176
+ for (const e of S.entries || []) e.g.eachLayer((lyr) => {
177
+ const on = S.sel && S.sel.li === e.layer.index && String(S.sel.id) === String(idOf(e, lyr.feature));
178
+ if (!lyr._mpe) return;
179
+ lyr.setStyle(on ? { color: "#7c5cff", weight: (lyr._mpe.weight || 2) + 3 } : { color: lyr._mpe.color, weight: lyr._mpe.weight });
180
+ if (on) lyr.bringToFront?.();
181
+ });
182
+ }
183
+ function featureOf(li, id) {
184
+ for (const e of S.entries || []) if (e.layer.index === li) {
185
+ let found = null;
186
+ e.g.eachLayer((lyr) => { if (String(idOf(e, lyr.feature)) === String(id)) found = { e, lyr, f: lyr.feature }; });
187
+ if (found) return found;
188
+ const f = e.layer.data.features.find((x) => String(idOf(e, x)) === String(id));
189
+ if (f) return { e, lyr: null, f };
190
+ }
191
+ return null;
192
+ }
193
+
194
+ // ---------------------------------------------------------------- seleção
195
+ function select(li, id, { focus = false } = {}) {
196
+ if (!featureOf(li, id)) { S.sel = null; highlight(); renderProps(); return; }
197
+ S.sel = { li, id }; S.layer = li;
198
+ highlight();
199
+ renderLayers();
200
+ renderProps();
201
+ showHandles();
202
+ if (focus) S.root.querySelector("[data-props] input:not([disabled])")?.focus();
203
+ }
204
+ function unselect() { S.sel = null; clearHandles(); highlight(); renderLayers(); renderProps(); }
43
205
 
44
206
  function setMode(m) {
45
207
  S.mode = m;
208
+ S.joinFrom = null;
46
209
  clearDraft();
47
- S.host.querySelectorAll(".me-bar [data-mode]").forEach((x) => x.classList.toggle("on", x.dataset.mode === m));
48
- S.box.classList.toggle("me-drawing", m !== "mover");
49
- status({ mover: "Arraste um ponto para corrigir a posição; clique numa linha ou área para editar os vértices (botão direito num vértice: apagar, dividir).", ponto: "Clique no mapa onde fica o ponto novo.", linha: "Clique para cada ponto da linha; duplo clique (ou Concluir) termina.", area: "Clique para cada canto da área; duplo clique (ou Concluir) fecha.", regua: "Clique nos pontos para medir; Concluir zera." }[m]);
50
- S.map.doubleClickZoom[m === "linha" || m === "area" ? "disable" : "enable"]();
210
+ S.root.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x.dataset.mode === m));
211
+ S.root.classList.toggle("mpe-drawing", m !== "selecionar");
212
+ tipFor();
213
+ }
214
+ function onKey(e) {
215
+ e.stopPropagation();
216
+ if (/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) { if (e.key === "Escape") e.target.blur(); return; }
217
+ if (e.key === "Enter" && S.draft) { e.preventDefault(); finishShape(); }
218
+ else if (e.key === "Escape") { if (S.joinFrom) { S.joinFrom = null; tipFor(); } else if (S.draft) { clearDraft(); tipFor(); } else if (S.sel) unselect(); else if (S.mode !== "selecionar") setMode("selecionar"); }
219
+ else if ((e.key === "Delete" || e.key === "Backspace") && S.sel) { e.preventDefault(); deleteFeature(); }
220
+ }
221
+
222
+ // ---------------------------------------------------------------- gravação
223
+ // arquivo do projeto: em prévia, o servidor cria a cópia antes de gravar e devolve "materialized" (o Studio avisa)
224
+ async function fileWrite(url, body) {
225
+ const j = await post(url, body);
226
+ if (j.materialized) S.ctx.materialized?.(j.materialized);
227
+ return j;
228
+ }
229
+ // gravou: o Studio grava o deck e redesenha o slide; a tela volta com os dados novos (resume)
230
+ function changed() { state("Gravando…"); S.commit(); }
231
+ async function guarded(fn) {
232
+ try { await fn(); } catch (e) { state(`Não gravou: ${e.message}`, true); S?.ctx.toast?.(`Não gravou: ${e.message}`, 6000); }
233
+ }
234
+ async function exists(p) { try { return (await fetch(`api/project/file?path=${encodeURIComponent(p)}`, { method: "HEAD" })).ok; } catch { return false; } }
235
+
236
+ // pontos: o cabeçalho e as linhas de onde a camada lê (planilha ou lista no slide) e como gravar de volta.
237
+ // _linha (do motor) = posição da linha em rows + 1
238
+ async function pointRows(li) {
239
+ const l = S.slide.layers[li];
240
+ if (typeof l.points === "string") {
241
+ const j = await getJSON(`api/project/sheet?path=${encodeURIComponent(l.points)}&all=1`).catch(() => null);
242
+ const rows = j?.sheets?.[0]?.rows?.length ? j.sheets[0].rows.map((r) => r.map((c) => String(c ?? ""))) : [["nome", "endereco", "latitude", "longitude"]];
243
+ return { rows, save: (rows) => fileWrite("api/project/sheet-write", { path: l.points, rows }) };
244
+ }
245
+ const list = Array.isArray(l.points) ? l.points : [];
246
+ const head = [...new Set(list.flatMap((p) => Object.keys(p || {})))];
247
+ if (!head.length) head.push("nome");
248
+ if (!head.some(isLat)) head.push("lat");
249
+ if (!head.some(isLon)) head.push("lon");
250
+ const rows = [head, ...list.map((p) => head.map((h) => String(p?.[h] ?? "")))];
251
+ return { rows, save: async (rows) => { const h = rows[0]; l.points = rows.slice(1).map((r) => Object.fromEntries(h.map((k, i) => [k, asValue(r[i] ?? "")]).filter(([, v]) => v !== ""))); } };
252
+ }
253
+ // linhas e áreas: o FeatureCollection de onde a camada lê (arquivo ou slide); _i = posição em features
254
+ async function shapeSet(li) {
255
+ const l = S.slide.layers[li];
256
+ if (typeof l.geojson === "string") {
257
+ let fc = { type: "FeatureCollection", features: [] };
258
+ const r = await fetch(`api/project/file?path=${encodeURIComponent(l.geojson)}`);
259
+ if (r.ok) { try { const j = await r.json(); fc = j.type === "FeatureCollection" ? j : { type: "FeatureCollection", features: j.type === "Feature" ? [j] : j.coordinates ? [{ type: "Feature", properties: {}, geometry: j }] : [] }; } catch { /* arquivo vazio ou inválido: começa do zero */ } }
260
+ return { fc, save: (fc) => fileWrite("api/project/write", { path: l.geojson, text: JSON.stringify(fc, null, 1) }) };
261
+ }
262
+ const g = l.geojson;
263
+ const fc = !g ? { type: "FeatureCollection", features: [] } : g.type === "FeatureCollection" ? structuredClone(g) : g.type === "Feature" ? { type: "FeatureCollection", features: [structuredClone(g)] } : { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: structuredClone(g) }] };
264
+ return { fc, save: async (fc) => { l.geojson = fc; } };
265
+ }
266
+ const slideLayer = (li) => S.slide.layers?.[li];
267
+
268
+ async function newLayer(kind) {
269
+ const layers = (S.slide.layers ||= []);
270
+ let layer;
271
+ if (kind === "regioes") layer = { name: "Regiões", areas: {} };
272
+ else {
273
+ const [base, ext] = kind === "pontos" ? ["pontos", "csv"] : ["desenhos", "geojson"];
274
+ let p = `contexto/${base}.${ext}`;
275
+ for (let n = 2; await exists(p); n++) p = `contexto/${base}-${n}.${ext}`;
276
+ if (kind === "pontos") { await fileWrite("api/project/sheet-write", { path: p, rows: [["nome", "endereco", "latitude", "longitude"]] }); layer = { name: "Pontos", points: p, label: "nome" }; }
277
+ else { await fileWrite("api/project/write", { path: p, text: JSON.stringify({ type: "FeatureCollection", features: [] }, null, 1) }); layer = { name: "Desenhos", geojson: p, label: "nome" }; }
278
+ }
279
+ layers.push(layer);
280
+ S.layer = layers.length - 1;
281
+ return S.layer;
282
+ }
283
+ // camada que recebe o que a pessoa cria: a escolhida, se for do tipo certo; senão a primeira do tipo; senão uma nova
284
+ async function targetFor(kind) {
285
+ const l = S.slide.layers?.[S.layer];
286
+ if (l && kindOf(l) === kind) return S.layer;
287
+ const other = (S.slide.layers || []).findIndex((x) => kindOf(x) === kind);
288
+ if (other >= 0) return (S.layer = other);
289
+ return newLayer(kind);
290
+ }
291
+
292
+ async function addPoint(ll) {
293
+ await guarded(async () => {
294
+ const li = await targetFor("pontos");
295
+ const src = await pointRows(li);
296
+ const rows = src.rows, head = rows[0];
297
+ let iLat = head.findIndex(isLat), iLon = head.findIndex(isLon);
298
+ if (iLat < 0 || iLon < 0) { head.push("latitude", "longitude"); iLat = head.length - 2; iLon = head.length - 1; }
299
+ const row = head.map(() => "");
300
+ row[iLat] = ll.lat.toFixed(6); row[iLon] = ll.lng.toFixed(6);
301
+ const iAddr = head.findIndex((h) => ADDR.includes(norm(h)));
302
+ if (iAddr >= 0) { tip("Procurando o endereço deste lugar…"); try { row[iAddr] = (await post("api/mapa/endereco", { lat: ll.lat, lon: ll.lng })).endereco || ""; } catch (e) { tip(`Endereço não achado: ${e.message}`); } }
303
+ rows.push(row);
304
+ S.pendingSel = { li, id: rows.length, focus: true };
305
+ setMode("selecionar");
306
+ await src.save(rows);
307
+ changed();
308
+ });
309
+ }
310
+ function dragPoint(lyr, li, id, ev) {
311
+ window.L.DomEvent.stop(ev);
312
+ const map = S.map, start = ev.containerPoint;
313
+ let moved = false;
314
+ map.dragging.disable();
315
+ const move = (m) => { if (!moved && m.containerPoint.distanceTo(start) < 4) return; moved = true; lyr.setLatLng(m.latlng); };
316
+ const up = () => {
317
+ map.off("mousemove", move); map.off("mouseup", up); map.dragging.enable();
318
+ if (!moved) return;
319
+ S.noClick = true; setTimeout(() => { if (S) S.noClick = false; }, 60);
320
+ const ll = lyr.getLatLng();
321
+ S.pendingSel = { li, id };
322
+ guarded(async () => {
323
+ const src = await pointRows(li), rows = src.rows, head = rows[0];
324
+ const iLat = head.findIndex(isLat), iLon = head.findIndex(isLon);
325
+ if (iLat < 0 || iLon < 0 || !rows[id - 1]) return;
326
+ rows[id - 1][iLat] = ll.lat.toFixed(6); rows[id - 1][iLon] = ll.lng.toFixed(6);
327
+ await src.save(rows);
328
+ changed();
329
+ });
330
+ };
331
+ map.on("mousemove", move); map.on("mouseup", up);
51
332
  }
52
333
 
53
334
  // ---------------------------------------------------------------- desenho (linha, área, régua)
54
- function clearDraft() { S.draft?.layer.remove(); S.draft?.marks.forEach((x) => x.remove()); S.draft = null; }
335
+ function clearDraft() { if (!S?.draft) return; S.draft.layer.remove(); S.draft.marks.forEach((x) => x.remove()); S.draft = null; }
336
+ function onMapClick(ev) {
337
+ if (!S || S.noClick) return;
338
+ if (S.mode === "selecionar") { if (S.sel) unselect(); return; }
339
+ if (S.mode === "ponto") return addPoint(ev.latlng);
340
+ addVertex(ev.latlng);
341
+ }
55
342
  async function addVertex(ll) {
56
343
  const L = window.L;
57
- if (!S.draft) S.draft = { pts: [], layer: (S.mode === "area" ? L.polygon([], { color: "#d6336c", weight: 4, fillOpacity: 0.15 }) : L.polyline([], { color: "#d6336c", weight: 5, dashArray: S.mode === "regua" ? "8 8" : null })).addTo(S.map), marks: [] };
58
- const d = S.draft;
59
- const last = d.pts.at(-1);
344
+ if (!S.draft) S.draft = { pts: [], layer: (S.mode === "area" ? L.polygon([], { color: "#7c5cff", weight: 4, fillOpacity: 0.15 }) : L.polyline([], { color: "#7c5cff", weight: 5, dashArray: S.mode === "regua" ? "8 8" : null })).addTo(S.map), marks: [] };
345
+ const d = S.draft, last = d.pts.at(-1);
60
346
  if (S.mode === "linha" && S.ruas && last) {
61
- status("procurando o caminho pelas ruas…");
62
- try {
63
- const r = await post("api/mapa/rota", { pontos: [last, [ll.lat, ll.lng]], modo: S.modo });
64
- d.pts.push(...r.coords.slice(1).map(([lon, lat]) => [lat, lon]));
65
- } catch (e) { status(e.message); return; }
347
+ tip("Procurando o caminho pelas ruas…");
348
+ try { const r = await post("api/mapa/rota", { pontos: [last, [ll.lat, ll.lng]], modo: S.modo }); d.pts.push(...r.coords.slice(1).map(([lon, lat]) => [lat, lon])); }
349
+ catch (e) { tip(`${e.message} Desligue "Seguir as ruas" para ligar em linha reta.`); return; }
66
350
  } else d.pts.push([ll.lat, ll.lng]);
67
- d.marks.push(L.circleMarker(ll, { radius: 5, color: "#d6336c", fillOpacity: 1 }).addTo(S.map));
351
+ d.marks.push(L.circleMarker(ll, { radius: 5, color: "#7c5cff", fillOpacity: 1 }).addTo(S.map));
68
352
  d.layer.setLatLngs(d.pts);
69
- const len = d.pts.reduce((s, p, i) => (i ? s + S.map.distance(d.pts[i - 1], p) : 0), 0);
70
- status(S.mode === "area" && d.pts.length > 2 ? `área: ${fmtArea(ringArea(d.pts))}` : `comprimento: ${fmtLen(len)}`);
353
+ const len = lineLen(d.pts.map(([a, b]) => [b, a]));
354
+ tipFor();
355
+ tip(S.mode === "area" && d.pts.length > 2 ? `Área: ${fmtArea(ringArea(d.pts))}. Duplo clique ou Enter fecha; Esc desfaz.` : `Comprimento: ${fmtLen(len)}.${S.mode === "regua" ? " Esc zera." : " Duplo clique ou Enter termina; Esc desfaz."}`);
71
356
  }
72
-
73
357
  async function finishShape() {
74
358
  const d = S.draft;
75
359
  if (!d) return;
76
- if (S.mode === "regua") { clearDraft(); status("Régua zerada."); return; }
77
- if (d.pts.length < (S.mode === "area" ? 3 : 2)) { status("Faltam pontos."); return; }
78
- const nome = await ask(S.mode === "area" ? "Nome da área" : "Nome da linha", "");
79
- if (nome == null) return;
360
+ if (S.mode === "regua") { clearDraft(); tipFor(); return; }
361
+ if (d.pts.length < (S.mode === "area" ? 3 : 2)) { tip(S.mode === "area" ? "Uma área precisa de pelo menos 3 cantos." : "Uma linha precisa de pelo menos 2 pontos."); return; }
80
362
  const coords = d.pts.map(([lat, lon]) => [lon, lat]);
81
363
  const geometry = S.mode === "area" ? { type: "Polygon", coordinates: [[...coords, coords[0]]] } : { type: "LineString", coordinates: coords };
82
364
  clearDraft();
83
- await saveShape({ type: "Feature", properties: { nome }, geometry });
84
- }
85
-
86
- // ---------------------------------------------------------------- gravação
87
- async function shapeTarget() {
88
- const sel = S.host.querySelector(".me-bar [data-alvo-forma]").value;
89
- if (sel !== "novo") return S.slide.layers[Number(sel)].geojson;
90
- const p = "contexto/desenhos.geojson";
91
- (S.slide.layers ||= []).push({ name: "Desenhos", geojson: p, label: "nome" });
92
- return p;
93
- }
94
- async function readGeo(p) {
95
- const r = await fetch(`api/project/file?path=${encodeURIComponent(p)}`);
96
- if (!r.ok) return { type: "FeatureCollection", features: [] };
97
- try { const j = await r.json(); return j.type === "FeatureCollection" ? j : { type: "FeatureCollection", features: j.type === "Feature" ? [j] : [] }; } catch { return { type: "FeatureCollection", features: [] }; }
98
- }
99
- const writeGeo = (p, fc) => post("api/project/write", { path: p, text: JSON.stringify(fc, null, 1) });
100
- async function saveShape(feature) {
101
- const p = await shapeTarget();
102
- const fc = await readGeo(p);
103
- fc.features.push(feature);
104
- try { await writeGeo(p, fc); } catch (e) { status(`Não gravou: ${e.message}`); return; }
105
- status(`Gravado em ${p}.`);
106
- S.commit();
107
- }
108
-
109
- async function savePoint(ll) {
110
- const sel = S.host.querySelector(".me-bar [data-alvo-ponto]").value;
111
- let p, rows;
112
- if (sel === "novo") {
113
- p = "contexto/pontos.csv";
114
- const cur = await getJSON(`api/project/sheet?path=${encodeURIComponent(p)}&all=1`).catch(() => null);
115
- rows = cur?.sheets?.[0]?.rows?.length ? cur.sheets[0].rows : [["nome", "endereco", "latitude", "longitude"]];
116
- if (!(S.slide.layers || []).some((l) => l.points === p)) (S.slide.layers ||= []).push({ name: "Pontos", points: p, label: "nome" });
117
- } else {
118
- p = S.slide.layers[Number(sel)].points;
119
- rows = (await getJSON(`api/project/sheet?path=${encodeURIComponent(p)}&all=1`)).sheets[0].rows.map((r) => [...r]);
120
- }
121
- const head = rows[0].map((h) => String(h).trim());
122
- let iLat = head.findIndex((h) => LAT.includes(norm(h))), iLon = head.findIndex((h) => LON.includes(norm(h)));
123
- if (iLat < 0 || iLon < 0) { head.push("latitude", "longitude"); rows[0] = head; iLat = head.length - 2; iLon = head.length - 1; }
124
- const iAddr = head.findIndex((h) => ADDR.includes(norm(h)));
125
- let endereco = "";
126
- if (iAddr >= 0) { status("procurando o endereço deste ponto…"); try { endereco = (await post("api/mapa/endereco", { lat: ll.lat, lon: ll.lng })).endereco || ""; } catch (e) { status(e.message); } }
127
- const values = await form(head.filter((_, i) => i !== iLat && i !== iLon), { [head[iAddr]]: endereco });
128
- if (!values) { status(""); return; }
129
- const row = head.map((h, i) => (i === iLat ? ll.lat.toFixed(6) : i === iLon ? ll.lng.toFixed(6) : values[h] ?? ""));
130
- rows.push(row);
131
- try { await post("api/project/sheet-write", { path: p, rows }); } catch (e) { status(`Não gravou: ${e.message}`); return; }
132
- status(`Ponto gravado em ${p} (linha ${rows.length}).`);
133
- S.commit();
134
- }
135
-
136
- async function movePoint(layerIdx, linha, ll) {
137
- const p = S.slide.layers[layerIdx]?.points;
138
- if (typeof p !== "string" || !linha) return;
139
- const rows = (await getJSON(`api/project/sheet?path=${encodeURIComponent(p)}&all=1`)).sheets[0].rows.map((r) => [...r]);
140
- const head = rows[0].map((h) => String(h).trim());
141
- const iLat = head.findIndex((h) => LAT.includes(norm(h))), iLon = head.findIndex((h) => LON.includes(norm(h)));
142
- if (iLat < 0 || iLon < 0 || !rows[linha - 1]) return;
143
- rows[linha - 1][iLat] = ll.lat.toFixed(6); rows[linha - 1][iLon] = ll.lng.toFixed(6);
144
- await post("api/project/sheet-write", { path: p, rows });
145
- status(`Posição atualizada (linha ${linha}).`);
146
- S.commit();
365
+ await guarded(async () => {
366
+ const li = await targetFor("formas");
367
+ const src = await shapeSet(li);
368
+ src.fc.features.push({ type: "Feature", properties: { nome: "" }, geometry });
369
+ S.pendingSel = { li, id: src.fc.features.length - 1, focus: true };
370
+ setMode("selecionar");
371
+ await src.save(src.fc);
372
+ changed();
373
+ });
147
374
  }
148
375
 
149
- // ---------------------------------------------------------------- vértices: arrastar, apagar, dividir, juntar
150
- async function editShape(entry, lyr) {
151
- const L = window.L, p = entry.layer.source;
152
- if (typeof p !== "string" || !/\.geojson$/i.test(p)) { status("Só linhas e áreas de GeoJSON se editam aqui (GPX e KML: abra no programa de origem)."); return; }
153
- S.handles?.forEach((h) => h.remove());
154
- const fc = await readGeo(p);
155
- const idx = entry.layer.data.features.indexOf(lyr.feature);
156
- const f = fc.features[idx];
157
- if (!f) return;
158
- const isArea = f.geometry.type === "Polygon";
159
- const ring = isArea ? f.geometry.coordinates[0].slice(0, -1) : f.geometry.coordinates;
160
- const save = async () => {
161
- f.geometry.coordinates = isArea ? [[...ring, ring[0]]] : ring;
162
- await writeGeo(p, fc); S.commit();
163
- };
376
+ // ---------------------------------------------------------------- vértices da forma selecionada; juntar linhas
377
+ function clearHandles() { S?.handles?.forEach((h) => h.remove()); if (S) S.handles = []; }
378
+ function showHandles() {
379
+ clearHandles();
380
+ const { li, id } = S.sel || {};
381
+ const l = slideLayer(li);
382
+ if (kindOf(l) !== "formas" || !S.map) return;
383
+ const hit = featureOf(li, id), g = hit?.f.geometry;
384
+ if (!g || (g.type !== "LineString" && g.type !== "Polygon")) return;
385
+ const L = window.L, isArea = g.type === "Polygon";
386
+ const ring = (isArea ? g.coordinates[0].slice(0, -1) : g.coordinates).map((c) => [...c]);
387
+ const save = (mut) => guarded(async () => {
388
+ const src = await shapeSet(li), f = src.fc.features[id];
389
+ if (!f) return;
390
+ mut(f, src.fc);
391
+ S.pendingSel = { li, id };
392
+ await src.save(src.fc);
393
+ changed();
394
+ });
395
+ const setRing = (f) => { f.geometry.coordinates = isArea ? [[...ring, ring[0]]] : ring; };
164
396
  S.handles = ring.map(([lon, lat], i) => {
165
397
  const h = L.marker([lat, lon], { draggable: true, icon: L.divIcon({ className: "me-vertex", iconSize: [14, 14] }) }).addTo(S.map);
166
- h.on("dragend", () => { const q = h.getLatLng(); ring[i] = [q.lng, q.lat]; save(); });
398
+ h.on("dragend", () => { const q = h.getLatLng(); ring[i] = [q.lng, q.lat]; save(setRing); });
167
399
  h.on("contextmenu", async (e) => {
168
400
  L.DomEvent.stop(e);
169
- const choice = await menu(isArea || i === 0 || i === ring.length - 1 ? ["Apagar vértice"] : ["Apagar vértice", "Dividir a linha aqui"]);
170
- if (choice === "Apagar vértice" && ring.length > (isArea ? 3 : 2)) { ring.splice(i, 1); save(); }
171
- if (choice === "Dividir a linha aqui") {
172
- const a = ring.slice(0, i + 1), b = ring.slice(i);
173
- f.geometry.coordinates = a;
174
- fc.features.splice(idx + 1, 0, { type: "Feature", properties: { ...f.properties, nome: `${f.properties?.nome || "linha"} (2)` }, geometry: { type: "LineString", coordinates: b } });
175
- await writeGeo(p, fc); S.commit();
176
- }
401
+ const opts = isArea || i === 0 || i === ring.length - 1 ? ["Apagar este ponto"] : ["Apagar este ponto", "Dividir a linha aqui"];
402
+ const choice = await menu(opts);
403
+ if (choice === "Apagar este ponto" && ring.length > (isArea ? 3 : 2)) { ring.splice(i, 1); save(setRing); }
404
+ if (choice === "Dividir a linha aqui") save((f, fc) => {
405
+ f.geometry.coordinates = ring.slice(0, i + 1);
406
+ fc.features.splice(id + 1, 0, { type: "Feature", properties: { ...f.properties, nome: `${f.properties?.nome || "linha"} (2)` }, geometry: { type: "LineString", coordinates: ring.slice(i) } });
407
+ });
177
408
  });
178
409
  return h;
179
410
  });
180
- if (!isArea) {
181
- status("Arraste os vértices; botão direito apaga ou divide. Para juntar com outra linha, clique em Juntar e depois na outra.");
182
- const j = document.createElement("button"); j.type = "button"; j.textContent = "Juntar com outra linha"; j.className = "me-join";
183
- S.host.querySelector(".me-bar .me-row").append(j);
184
- j.onclick = () => { S.joinFrom = { p, fc, idx }; status("Clique na outra linha para juntar."); j.remove(); };
185
- }
186
- }
187
- async function joinWith(entry, lyr) {
188
- const from = S.joinFrom; S.joinFrom = null;
189
- if (!from || entry.layer.source !== from.p) { status("Só junto linhas da mesma camada."); return; }
190
- const idx2 = entry.layer.data.features.indexOf(lyr.feature);
191
- const a = from.fc.features[from.idx], b = from.fc.features[idx2];
192
- if (!a || !b || a === b || a.geometry.type !== "LineString" || b.geometry.type !== "LineString") return;
193
- const A = a.geometry.coordinates, B = b.geometry.coordinates;
194
- const d = (u, v) => Math.hypot(u[0] - v[0], u[1] - v[1]);
195
- // liga pelas pontas mais próximas
196
- const opts = [[A, B], [A, [...B].reverse()], [[...A].reverse(), B], [[...A].reverse(), [...B].reverse()]];
197
- const [x, y] = opts.reduce((best, o) => (d(o[0].at(-1), o[1][0]) < d(best[0].at(-1), best[1][0]) ? o : best));
198
- a.geometry.coordinates = [...x, ...y.slice(d(x.at(-1), y[0]) < 1e-9 ? 1 : 0)];
199
- from.fc.features.splice(idx2, 1);
200
- await writeGeo(from.p, from.fc); S.commit();
201
- }
202
-
203
- function useView() {
204
- const c = S.map.getCenter();
205
- S.slide.view = { center: [Number(c.lat.toFixed(5)), Number(c.lng.toFixed(5))], zoom: Math.round(S.map.getZoom() * 4) / 4 };
206
- status("Enquadramento gravado no slide.");
207
- S.commit();
208
- }
209
-
210
- // ---------------------------------------------------------------- caixinhas (formulário e menu)
211
- function modal(html) { const d = document.createElement("dialog"); d.className = "md-dialog"; d.innerHTML = html; document.body.append(d); d.addEventListener("close", () => d.remove()); d.showModal(); return d; }
212
- function ask(label, value) {
213
- return new Promise((res) => {
214
- const d = modal(`<h3>${esc(label)}</h3><input class="me-input" value="${esc(value)}"><div class="md-row"><button type="button" class="btn" data-no>Cancelar</button><button type="button" class="btn btn-primary" data-yes>Gravar</button></div>`);
215
- const i = d.querySelector("input"); i.focus();
216
- d.querySelector("[data-yes]").onclick = () => { res(i.value.trim()); d.close(); };
217
- d.querySelector("[data-no]").onclick = () => { res(null); d.close(); };
218
- i.onkeydown = (e) => { if (e.key === "Enter") d.querySelector("[data-yes]").click(); };
219
- });
220
411
  }
221
- function form(fields, initial) {
222
- return new Promise((res) => {
223
- const d = modal(`<h3>Ponto novo</h3>${fields.map((f) => `<label class="me-field">${esc(f)}<input data-f="${esc(f)}" value="${esc(initial[f] ?? "")}"></label>`).join("")}<div class="md-row"><button type="button" class="btn" data-no>Cancelar</button><button type="button" class="btn btn-primary" data-yes>Gravar</button></div>`);
224
- d.querySelector("input")?.focus();
225
- d.querySelector("[data-yes]").onclick = () => { res(Object.fromEntries([...d.querySelectorAll("[data-f]")].map((i) => [i.dataset.f, i.value.trim()]))); d.close(); };
226
- d.querySelector("[data-no]").onclick = () => { res(null); d.close(); };
412
+ // juntar: a linha selecionada com a próxima linha clicada (da mesma camada), pelas pontas mais próximas
413
+ async function joinWith(li, id2) {
414
+ const from = S.joinFrom; S.joinFrom = null; tipFor();
415
+ if (!from || from.li !== li || from.id === id2) { tip("Só junto duas linhas diferentes da mesma camada."); return; }
416
+ await guarded(async () => {
417
+ const src = await shapeSet(li), a = src.fc.features[from.id], b = src.fc.features[id2];
418
+ if (!a || !b || a.geometry?.type !== "LineString" || b.geometry?.type !== "LineString") { tip("Só junto linhas."); return; }
419
+ const A = a.geometry.coordinates, B = b.geometry.coordinates;
420
+ const d = (u, v) => Math.hypot(u[0] - v[0], u[1] - v[1]);
421
+ const opts = [[A, B], [A, [...B].reverse()], [[...A].reverse(), B], [[...A].reverse(), [...B].reverse()]];
422
+ const [x, y] = opts.reduce((best, o) => (d(o[0].at(-1), o[1][0]) < d(best[0].at(-1), best[1][0]) ? o : best));
423
+ a.geometry.coordinates = [...x, ...y.slice(d(x.at(-1), y[0]) < 1e-9 ? 1 : 0)];
424
+ src.fc.features.splice(id2, 1);
425
+ S.pendingSel = { li, id: from.id < id2 ? from.id : from.id - 1 };
426
+ await src.save(src.fc);
427
+ changed();
227
428
  });
228
429
  }
229
430
  function menu(items) {
230
431
  return new Promise((res) => {
231
- const d = modal(`<div class="me-menu">${items.map((t) => `<button type="button" class="btn" data-i="${esc(t)}">${esc(t)}</button>`).join("")}<button type="button" class="btn" data-i="">Cancelar</button></div>`);
432
+ const d = document.createElement("dialog"); d.className = "md-dialog";
433
+ d.innerHTML = `<div class="me-menu">${items.map((t) => `<button type="button" class="btn" data-i="${esc(t)}">${esc(t)}</button>`).join("")}<button type="button" class="btn" data-i="">Cancelar</button></div>`;
434
+ document.body.append(d); d.addEventListener("close", () => d.remove()); d.showModal();
232
435
  d.querySelectorAll("[data-i]").forEach((b) => (b.onclick = () => { res(b.dataset.i || null); d.close(); }));
233
436
  });
234
437
  }
235
438
 
236
- // ---------------------------------------------------------------- ligar e desligar
237
- function wire() {
238
- const inst = window.SagaMap.instance(S.box);
239
- if (!inst?.map) return false;
240
- S.map = inst.map;
241
- S.box.classList.add("me-on");
242
- S.map.on("click", onClick);
243
- S.map.on("dblclick", onDbl);
244
- // pontos arrastáveis (Mover) e formas que abrem os vértices
245
- inst.layers.forEach((entry, li) => entry.g.eachLayer((lyr) => {
246
- lyr.off("click"); lyr.closePopup?.(); lyr.unbindPopup?.();
247
- lyr.on("click", (e) => { window.L.DomEvent.stop(e); if (S.mode !== "mover") return onClick(e); if (S.joinFrom) return joinWith(entry, lyr); if (!lyr.getLatLng) editShape(entry, lyr); });
248
- if (lyr.getLatLng) lyr.on("mousedown", (e) => {
249
- if (S.mode !== "mover") return;
250
- window.L.DomEvent.stop(e);
251
- S.map.dragging.disable();
252
- const move = (ev) => lyr.setLatLng(ev.latlng);
253
- const up = (ev) => { S.map.off("mousemove", move); S.map.off("mouseup", up); S.map.dragging.enable(); movePoint(li, lyr.feature?.properties?._linha, ev.latlng); };
254
- S.map.on("mousemove", move); S.map.on("mouseup", up);
255
- });
256
- }));
257
- // a barra só aparece com tudo ligado: com ela à vista, clicar e arrastar já funcionam
258
- S.host.querySelectorAll(":scope > .me-bar").forEach((b) => b.remove());
259
- S.host.append(bar());
260
- setMode(S.mode);
261
- return true;
262
- }
263
- function onClick(e) {
264
- if (!S) return;
265
- if (S.mode === "ponto") savePoint(e.latlng);
266
- else if (S.mode === "linha" || S.mode === "area" || S.mode === "regua") addVertex(e.latlng);
439
+ // ---------------------------------------------------------------- camadas (esquerda)
440
+ function renderLayers() {
441
+ const ul = S.root.querySelector("[data-layers]");
442
+ const layers = S.slide.layers || [];
443
+ if (!layers.length) { ul.innerHTML = `<li class="mpe-empty">Nenhuma camada ainda. Escolha Ponto, Linha ou Área e clique no mapa, ou use Nova camada.</li>`; return; }
444
+ ul.innerHTML = layers.map((l, i) => {
445
+ const k = KINDS[kindOf(l)] || ["map", "Camada", "item", "itens"];
446
+ const n = S.entries?.find((e) => e.layer.index === i)?.layer.data.features.length;
447
+ const count = n == null ? "" : `${n} ${n === 1 ? k[2] : k[3]}`;
448
+ return `<li class="${i === S.layer ? "on" : ""}"><button type="button" class="mpe-l" data-pick="${i}" title="${esc(srcText(l))}">${ic(k[0])}<span class="mpe-ln">${esc(l.name || k[1])}</span><small>${esc(count)}</small></button>
449
+ <button type="button" class="mpe-eye" data-eye="${i}" title="${S.hidden.has(i) ? "Mostrar" : "Esconder"} aqui no editor (não muda a apresentação)" aria-label="${S.hidden.has(i) ? "Mostrar" : "Esconder"} ${esc(l.name || k[1])}">${ic(S.hidden.has(i) ? "eye-off" : "eye")}</button></li>`;
450
+ }).join("");
451
+ S.ctx.hydrate?.(ul);
452
+ }
453
+ function onLayersClick(e) {
454
+ const pick = e.target.closest("[data-pick]"), eye = e.target.closest("[data-eye]");
455
+ if (eye) {
456
+ const i = Number(eye.dataset.eye);
457
+ S.hidden.has(i) ? S.hidden.delete(i) : S.hidden.add(i);
458
+ const en = S.entries?.find((x) => x.layer.index === i);
459
+ if (en && S.map) { if (S.hidden.has(i)) S.map.removeLayer(en.g); else { en.g.addTo(S.map); window.SagaMap.instance(S.mbox)?.pointsOnTop?.(); } }
460
+ renderLayers();
461
+ } else if (pick) { S.layer = Number(pick.dataset.pick); S.sel = null; clearHandles(); highlight(); renderLayers(); renderProps(); tipFor(); }
462
+ }
463
+ async function newLayerMenu() {
464
+ const choice = await menu(["Pontos (uma planilha)", "Linhas e áreas (desenho)", "Regiões por valor (estados ou países)"]);
465
+ if (!choice) return;
466
+ const kind = choice.startsWith("Pontos") ? "pontos" : choice.startsWith("Linhas") ? "formas" : "regioes";
467
+ await guarded(async () => {
468
+ await newLayer(kind);
469
+ S.sel = null;
470
+ setMode(kind === "pontos" ? "ponto" : kind === "formas" ? "linha" : "selecionar");
471
+ changed();
472
+ });
267
473
  }
268
- function onDbl(e) { if (S && (S.mode === "linha" || S.mode === "area")) { window.L.DomEvent.stop(e); finishShape(); } }
269
474
 
270
- function start(box, slide, commit, ctx) {
271
- S = { box, host: document.getElementById("canvas-viewport") || box, slide, commit, ctx, mode: S?.mode || "mover", ruas: S?.ruas ?? true, modo: S?.modo || "pe", key: slide.uid || slide.title };
272
- // o slide redesenhou: a barra antiga sai na hora (ela ligava o mapa velho); a nova vem quando o mapa novo estiver ligado
273
- S.host.querySelectorAll(":scope > .me-bar").forEach((b) => b.remove());
274
- if (!wire()) { window.SagaMap.mount(box.closest(".slide") || box).then(() => S && wire()); }
475
+ // ---------------------------------------------------------------- propriedades (direita)
476
+ const entryOf = (li) => S.entries?.find((x) => x.layer.index === li);
477
+ function columnsOf(li) {
478
+ return [...new Set((entryOf(li)?.layer.data.features || []).flatMap((f) => Object.keys(f.properties || {})))].filter((c) => !c.startsWith("_"));
275
479
  }
276
- function stop() {
277
- if (!S) return;
278
- clearDraft(); S.handles?.forEach((h) => h.remove());
279
- S.host.querySelectorAll(":scope > .me-bar").forEach((b) => b.remove());
280
- S.box.classList.remove("me-on", "me-drawing");
281
- const box = S.box; S = null;
282
- window.SagaMapData?.editorStopped?.(box);
480
+ function hintFor(l, col, cols) {
481
+ const out = [];
482
+ if (ADDR.includes(norm(col))) out.push("veio do lugar clicado; corrija se precisar");
483
+ if (l.label === col) out.push("aparece escrito ao lado, no mapa");
484
+ if (l.color === col) out.push("define a cor (cada valor, uma cor)");
485
+ if (l.size === col) out.push("número: define o tamanho do ponto");
486
+ const popup = Array.isArray(l.popup) ? l.popup.includes(col) : l.popup !== false && cols.indexOf(col) < 8;
487
+ if (popup && l.label !== col) out.push("aparece no cartão ao clicar");
488
+ if (!out.length) return "Fica guardado, mas não aparece no mapa (escolha em Rótulo ou Cartão, na camada).";
489
+ const t = out.join("; ");
490
+ return t[0].toUpperCase() + t.slice(1) + ".";
491
+ }
492
+ function renderProps() {
493
+ const el = S.root.querySelector("[data-props]");
494
+ el.innerHTML = S.sel ? featureProps() : layerProps() + mapProps();
495
+ S.ctx.hydrate?.(el);
283
496
  }
284
- // o slide redesenhou (gravou algo): o editor volta no mesmo modo, na caixa nova
285
- function resume(box, slide, commit, ctx) { if (S && (slide.uid || slide.title) === S.key) start(box, slide, commit, ctx); }
286
- return { start, stop, resume, active: () => !!S, key: () => S?.key };
497
+ function featureProps() {
498
+ const { li, id } = S.sel, l = slideLayer(li), hit = featureOf(li, id);
499
+ if (!hit || !l) return "";
500
+ const kind = kindOf(l), f = hit.f, g = f.geometry || {};
501
+ const ro = !editable(l);
502
+ if (kind === "regioes") {
503
+ return `<section class="mpe-sec"><div class="mpe-kick">${ic("map")} Região · em "${esc(l.name || "Regiões")}"</div><h3>${esc(f.properties?.nome || id)}</h3>
504
+ <label class="mpe-f"><span>Valor</span><input data-region="${esc(id)}" value="${esc(l.areas?.[id] ?? "")}" inputmode="decimal"><small>A cor da região vem deste número: quanto maior, mais forte.</small></label>
505
+ <button type="button" class="btn mpe-del" data-del>${ic("trash-2")} Tirar esta região</button></section>`;
506
+ }
507
+ const isPt = g.type === "Point";
508
+ const what = isPt ? "Ponto" : /Polygon/.test(g.type) ? "Área" : "Linha";
509
+ const cols = columnsOf(li);
510
+ const fields = (cols.length ? cols : ["nome"]).map((c) => {
511
+ const vals = l.color === c ? [...new Set((hit.e.layer.data.features || []).map((x) => String(x.properties?.[c] ?? "").trim()).filter(Boolean))] : [];
512
+ return `<label class="mpe-f"><span>${esc(c)}</span><input data-fp="${esc(c)}" value="${esc(f.properties?.[c] ?? "")}" ${vals.length ? `list="mpe-dl-${esc(norm(c))}"` : ""} ${ro ? "disabled" : ""}><small>${esc(hintFor(l, c, cols))}</small></label>${vals.length ? `<datalist id="mpe-dl-${esc(norm(c))}">${vals.map((v) => `<option value="${esc(v)}">`).join("")}</datalist>` : ""}`;
513
+ }).join("");
514
+ let measure = "";
515
+ if (isPt) { const [lon, lat] = g.coordinates; measure = `Posição ${lat.toFixed(5)}, ${lon.toFixed(5)}.${ro ? "" : " Arraste o ponto no mapa para mudar."}`; }
516
+ else if (g.type === "LineString") measure = `Comprimento ${fmtLen(lineLen(g.coordinates))}.${ro ? "" : " Arraste os pontos brancos para mudar o traçado; botão direito num deles: apagar ou dividir."}`;
517
+ else if (g.type === "Polygon") measure = `Área ${fmtArea(ringArea(g.coordinates[0].map(([a, b]) => [b, a])))}.${ro ? "" : " Arraste os cantos brancos para mudar; botão direito num deles: apagar."}`;
518
+ const where = typeof l.points === "string" ? `Planilha ${l.points}, linha ${id}` : typeof l.geojson === "string" ? `Arquivo ${l.geojson}` : srcText(l);
519
+ return `<section class="mpe-sec"><div class="mpe-kick">${ic(isPt ? "map-pin" : what === "Área" ? "pentagon" : "spline")} ${what} · em "${esc(l.name || KINDS[kind]?.[1] || "Camada")}"</div>
520
+ <p class="mpe-src">${esc(where)}</p>
521
+ ${ro ? `<p class="mpe-help">Este trajeto vem de um arquivo ${l.gpx ? "GPX" : "KML"}: aqui ele só se mostra. Para mudar, edite no programa de origem.</p>` : `<p class="mpe-help">Cada campo é uma informação deste ${what === "Área" ? "desenho" : what.toLowerCase()} (uma coluna da camada). Todos são opcionais.</p>`}
522
+ ${fields}
523
+ ${ro ? "" : `<div class="mpe-addf"><input data-newcol placeholder="Campo novo (ex.: status)" aria-label="Nome do campo novo"><button type="button" class="btn btn-small" data-addcol>${ic("plus")} Adicionar</button></div>`}
524
+ <p class="mpe-measure">${esc(measure)}</p>
525
+ <div class="mpe-row">${!ro && g.type === "LineString" ? `<button type="button" class="btn btn-small" data-join>Juntar com outra linha</button>` : ""}
526
+ ${ro ? "" : `<button type="button" class="btn btn-small mpe-del" data-del>${ic("trash-2")} Excluir</button>`}</div></section>`;
527
+ }
528
+ function layerProps() {
529
+ const layers = S.slide.layers || [];
530
+ const l = layers[S.layer];
531
+ if (!l) return `<section class="mpe-sec"><div class="mpe-kick">${ic("layers")} Camada</div><p class="mpe-help">Este mapa ainda não tem camadas. Escolha Ponto, Linha ou Área na coluna das ferramentas e clique no mapa: a camada nasce sozinha.</p></section>`;
532
+ const kind = kindOf(l), k = KINDS[kind] || ["map", "Camada"];
533
+ const cols = columnsOf(S.layer);
534
+ const byCol = l.color && cols.includes(l.color);
535
+ const opt = (v, t, cur) => `<option value="${esc(v)}" ${String(cur ?? "") === v ? "selected" : ""}>${esc(t)}</option>`;
536
+ const feats = entryOf(S.layer)?.layer.data.features || [];
537
+ const nums = cols.filter((c) => feats.some((f) => String(f.properties?.[c] ?? "").trim()) && feats.every((f) => { const v = String(f.properties?.[c] ?? "").trim(); return !v || /^-?\d+([.,]\d+)?$/.test(v); }));
538
+ const popup = Array.isArray(l.popup) ? l.popup : l.popup === false ? [] : cols.slice(0, 8);
539
+ let style = "";
540
+ if (kind === "regioes") {
541
+ const rows = Object.entries(l.areas || {}).map(([c, v]) => `<tr><td>${esc(c)}</td><td><input data-region="${esc(c)}" value="${esc(v)}" inputmode="decimal" aria-label="Valor de ${esc(c)}"></td><td><button type="button" class="mpe-x" data-delregion="${esc(c)}" aria-label="Tirar ${esc(c)}">${ic("x")}</button></td></tr>`).join("");
542
+ style = `<div class="mpe-h2">Valores</div><table class="mpe-table"><thead><tr><th>Sigla</th><th>Valor</th><th></th></tr></thead><tbody>${rows}</tbody></table>
543
+ <div class="mpe-addf"><input data-newregion placeholder="SP, RJ… ou BR, PT…" aria-label="Sigla do estado ou país"><input data-newregionv placeholder="valor" inputmode="decimal" aria-label="Valor"><button type="button" class="btn btn-small" data-addregion>${ic("plus")} Pôr</button></div>
544
+ <p class="mpe-help">Estados pela sigla (SP, MG…) ou países pelo código (BR, PT…). A cor mais forte é o maior valor.</p>
545
+ <label class="mpe-f"><span>Unidade na legenda</span><input data-lp="legend" value="${esc(l.legend || "")}" placeholder="ex.: MW, mil habitantes"></label>`;
546
+ } else if (kind) {
547
+ style = `<div class="mpe-h2">Cor</div>
548
+ <div class="mpe-seg"><button type="button" data-colormode="fixa" class="${byCol ? "" : "on"}">Uma cor</button><button type="button" data-colormode="coluna" class="${byCol ? "on" : ""}" ${cols.length ? "" : "disabled"}>Pela coluna</button></div>
549
+ ${byCol ? `<label class="mpe-f"><select data-lp="color" aria-label="Coluna da cor">${cols.map((c) => opt(c, c, l.color)).join("")}</select><small>Cada valor ganha uma cor (número vira escala). A legenda mostra as cores.</small></label>`
550
+ : `<div class="mpe-sw">${SWATCHES.map(([c, t]) => `<button type="button" data-swatch="${c}" class="${(l.color || "c1") === c ? "on" : ""}" title="${t}" aria-label="${t}" style="--sw:var(--c-${c},var(--${c},#888))"></button>`).join("")}</div>`}
551
+ ${kind === "pontos" ? `<label class="mpe-f"><span>Tamanho do ponto</span><select data-lp="size">${opt("", "Todos iguais", l.size)}${nums.map((c) => opt(c, `Pela coluna ${c}`, l.size)).join("")}</select></label>` : ""}
552
+ <label class="mpe-f"><span>Rótulo escrito no mapa</span><select data-lp="label">${opt("", "Nenhum", l.label)}${cols.map((c) => opt(c, c, l.label)).join("")}</select></label>
553
+ ${cols.length ? `<div class="mpe-h2">O cartão (ao clicar) mostra</div><div class="mpe-checks">${cols.map((c) => `<label><input type="checkbox" data-popup="${esc(c)}" ${popup.includes(c) ? "checked" : ""}> ${esc(c)}</label>`).join("")}</div>` : ""}`;
554
+ }
555
+ return `<section class="mpe-sec"><div class="mpe-kick">${ic(k[0])} Camada: ${esc(k[1])}</div>
556
+ <label class="mpe-f mpe-big"><span>Nome na legenda</span><input data-lp="name" value="${esc(l.name || "")}"></label>
557
+ <p class="mpe-src">${esc(srcText(l))}</p>
558
+ ${kind === "pontos" || kind === "formas" || kind === "arquivo" ? `<p class="mpe-help">Clique num item no mapa para ver e mudar o que ele tem.</p>` : ""}
559
+ ${style}
560
+ <div class="mpe-h2">Ao apresentar</div>
561
+ <label class="mpe-f"><span>Aparece</span><select data-lp="step">${opt("", S.slide.build ? "No clique, na ordem da lista" : "Desde o começo", l.step)}${[1, 2, 3, 4, 5, 6].map((n) => opt(String(n), `No ${n}º clique`, l.step)).join("")}</select></label>
562
+ <label class="mpe-chk"><input type="checkbox" data-lp="viewfit" ${l.view === "fit" ? "checked" : ""}> Quando aparece, a câmera vai até esta camada</label>
563
+ <div class="mpe-row"><button type="button" class="btn btn-small" data-move="-1" ${S.layer === 0 ? "disabled" : ""}>${ic("arrow-up")} Subir</button><button type="button" class="btn btn-small" data-move="1" ${S.layer === layers.length - 1 ? "disabled" : ""}>${ic("arrow-down")} Descer</button>
564
+ <button type="button" class="btn btn-small mpe-del" data-dellayer>${ic("trash-2")} Tirar do mapa</button></div></section>`;
565
+ }
566
+ function mapProps() {
567
+ const s = S.slide, opt = (v, t) => `<option value="${v}" ${(s.basemap || "ruas") === v ? "selected" : ""}>${t}</option>`;
568
+ const fixed = s.view && s.view !== "fit";
569
+ return `<section class="mpe-sec"><div class="mpe-kick">${ic("map")} O mapa</div>
570
+ <label class="mpe-f"><span>Fundo</span><select data-mp="basemap">${opt("ruas", "Ruas")}${opt("claro", "Claro")}${opt("escuro", "Escuro")}${opt("satelite", "Satélite (se configurado)")}${opt("nenhum", "Sem fundo")}</select></label>
571
+ <label class="mpe-chk"><input type="checkbox" data-mp="build" ${s.build ? "checked" : ""}> Uma camada por clique ao apresentar</label>
572
+ <div class="mpe-h2">Enquadramento</div>
573
+ <p class="mpe-help">${fixed ? "Fixo: abre como você deixou." : "Automático: mostra todos os dados."}</p>
574
+ <div class="mpe-row"><button type="button" class="btn btn-small" data-vista>${ic("crosshair")} Usar a vista de agora</button>${fixed ? `<button type="button" class="btn btn-small" data-autovista>Voltar ao automático</button>` : ""}</div></section>`;
575
+ }
576
+
577
+ async function setFeatureProp(li, id, col, value) {
578
+ const l = slideLayer(li);
579
+ S.pendingSel = { li, id };
580
+ if (kindOf(l) === "pontos") {
581
+ const src = await pointRows(li), rows = src.rows;
582
+ let c = rows[0].indexOf(col);
583
+ if (c < 0) { rows[0].push(col); c = rows[0].length - 1; }
584
+ if (!rows[id - 1]) return;
585
+ rows[id - 1][c] = value;
586
+ await src.save(rows);
587
+ } else {
588
+ const src = await shapeSet(li), f = src.fc.features[id];
589
+ if (!f) return;
590
+ f.properties = { ...(f.properties || {}), [col]: value };
591
+ await src.save(src.fc);
592
+ }
593
+ changed();
594
+ }
595
+ async function deleteFeature() {
596
+ const { li, id } = S.sel || {};
597
+ const l = slideLayer(li);
598
+ if (!l || !editable(l)) return;
599
+ await guarded(async () => {
600
+ S.sel = null; clearHandles();
601
+ if (kindOf(l) === "regioes") { const a = { ...l.areas }; delete a[id]; l.areas = a; }
602
+ else if (kindOf(l) === "pontos") { const src = await pointRows(li); src.rows.splice(id - 1, 1); await src.save(src.rows); }
603
+ else { const src = await shapeSet(li); src.fc.features.splice(id, 1); await src.save(src.fc); }
604
+ changed();
605
+ });
606
+ }
607
+ function onPropsChange(e) {
608
+ const t = e.target;
609
+ if (t.dataset.newcol != null || t.dataset.newregion != null || t.dataset.newregionv != null) return;
610
+ const l = slideLayer(S.layer);
611
+ guarded(async () => {
612
+ if (t.dataset.fp != null && S.sel) return setFeatureProp(S.sel.li, S.sel.id, t.dataset.fp, t.value.trim());
613
+ if (t.dataset.region != null) { const reg = slideLayer(S.sel?.li ?? S.layer); reg.areas = { ...reg.areas, [t.dataset.region]: asValue(t.value) }; if (S.sel) S.pendingSel = { ...S.sel }; return changed(); }
614
+ if (t.dataset.popup != null) {
615
+ const cols = columnsOf(S.layer), cur = Array.isArray(l.popup) ? l.popup : l.popup === false ? [] : cols.slice(0, 8);
616
+ l.popup = t.checked ? [...new Set([...cur, t.dataset.popup])] : cur.filter((c) => c !== t.dataset.popup);
617
+ return changed();
618
+ }
619
+ if (t.dataset.lp) {
620
+ const k = t.dataset.lp, v = t.type === "checkbox" ? t.checked : t.value.trim();
621
+ if (k === "viewfit") { if (v) l.view = "fit"; else delete l.view; }
622
+ else if (k === "step") { if (v === "") delete l.step; else l.step = Number(v); }
623
+ else if (v === "" || v === false) delete l[k]; else l[k] = v;
624
+ return changed();
625
+ }
626
+ if (t.dataset.mp) {
627
+ const k = t.dataset.mp, v = t.type === "checkbox" ? t.checked : t.value;
628
+ if (k === "build") { if (v) S.slide.build = true; else delete S.slide.build; } else S.slide[k] = v;
629
+ return changed();
630
+ }
631
+ });
632
+ }
633
+ function onPropsClick(e) {
634
+ const b = e.target.closest("button");
635
+ if (!b) return;
636
+ const l = slideLayer(S.layer);
637
+ if (b.dataset.del != null) return deleteFeature();
638
+ if (b.dataset.join != null) { S.joinFrom = { ...S.sel }; tipFor(); return; }
639
+ if (b.dataset.addcol != null) {
640
+ const col = S.root.querySelector("[data-newcol]").value.trim();
641
+ if (!col || !S.sel) return;
642
+ return guarded(() => setFeatureProp(S.sel.li, S.sel.id, col, ""));
643
+ }
644
+ guarded(async () => {
645
+ if (b.dataset.colormode === "coluna") { const c = columnsOf(S.layer)[0]; if (c) { l.color = c; return changed(); } }
646
+ if (b.dataset.colormode === "fixa") { l.color = "c1"; delete l.colors; return changed(); }
647
+ if (b.dataset.swatch) { l.color = b.dataset.swatch; delete l.colors; return changed(); }
648
+ if (b.dataset.delregion) { const a = { ...l.areas }; delete a[b.dataset.delregion]; l.areas = a; return changed(); }
649
+ if (b.dataset.addregion != null) {
650
+ const code = S.root.querySelector("[data-newregion]").value.trim().toUpperCase(), v = S.root.querySelector("[data-newregionv]").value;
651
+ if (!code) return;
652
+ l.areas = { ...(l.areas || {}), [code]: asValue(v || 0) };
653
+ return changed();
654
+ }
655
+ if (b.dataset.move) {
656
+ const to = S.layer + Number(b.dataset.move), layers = S.slide.layers;
657
+ if (to < 0 || to >= layers.length) return;
658
+ [layers[S.layer], layers[to]] = [layers[to], layers[S.layer]];
659
+ S.layer = to; S.hidden.clear();
660
+ return changed();
661
+ }
662
+ if (b.dataset.dellayer != null) {
663
+ const ok = await menu([`Tirar "${l.name || "esta camada"}" do mapa`]);
664
+ if (!ok) return;
665
+ S.slide.layers.splice(S.layer, 1);
666
+ S.layer = Math.max(0, S.layer - 1); S.hidden.clear();
667
+ return changed();
668
+ }
669
+ if (b.dataset.vista != null && S.map) { const c = S.map.getCenter(); S.slide.view = { center: [Number(c.lat.toFixed(5)), Number(c.lng.toFixed(5))], zoom: Math.round(S.map.getZoom() * 4) / 4 }; return changed(); }
670
+ if (b.dataset.autovista != null) { delete S.slide.view; return changed(); }
671
+ });
672
+ }
673
+
674
+ // compatível com map-data.js: start abre a tela; stop fecha; resume volta depois de gravar
675
+ return { start: open, stop: close, resume, active: () => !!S, key: () => S?.key };
287
676
  })();