sagadeck 1.7.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -4
- package/SKILL.md +10 -3
- package/bin/sagadeck.js +23 -0
- package/docs/ANDAMENTO.md +483 -1
- package/docs/PLANO-MELHORIAS.md +180 -0
- package/docs/REFERENCIA.md +184 -105
- package/package.json +34 -2
- package/src/ai/commands.js +2 -2
- package/src/ai/deck-ai.js +14 -5
- package/src/ai/ia-check.js +127 -0
- package/src/ai/ia-config.js +2 -2
- package/src/ai/llm.js +5 -1
- package/src/ai/map-analysis-run.js +119 -0
- package/src/ai/map-data.js +221 -0
- package/src/ai/quality.js +1 -1
- package/src/ai/video-generation.js +70 -25
- package/src/build.js +51 -15
- package/src/elements.js +39 -1
- package/src/export/browser.js +23 -1
- package/src/export/pptx.js +9 -3
- package/src/export/shots.js +10 -3
- package/src/fiscal/normalize.js +19 -0
- package/src/fs-retry.js +37 -2
- package/src/import/office.js +5 -4
- package/src/layouts.js +114 -99
- package/src/lessons.js +1 -1
- package/src/library.js +7 -7
- package/src/map-analysis.js +109 -0
- package/src/map-config.js +89 -0
- package/src/map-routing.js +82 -0
- package/src/map-services.js +60 -0
- package/src/map.js +274 -0
- package/src/mcp/server.js +17 -0
- package/src/motion.js +20 -6
- package/src/purpose.js +1 -1
- package/src/runtime/base.css +105 -329
- package/src/runtime/fit.js +3 -2
- package/src/runtime/fonts/amatic-sc.css +3 -0
- package/src/runtime/fonts/bebas-neue.css +2 -0
- package/src/runtime/fonts/cabin-sketch.css +2 -0
- package/src/runtime/fonts/fraunces.css +3 -0
- package/src/runtime/fonts/gelasio.css +3 -0
- package/src/runtime/fonts/ibm-plex-mono.css +4 -0
- package/src/runtime/fonts/index.json +10 -1
- package/src/runtime/fonts/itim.css +2 -0
- package/src/runtime/fonts/montserrat.css +2 -0
- package/src/runtime/fonts/space-grotesk.css +2 -0
- package/src/runtime/map-background.js +91 -0
- package/src/runtime/map.js +266 -0
- package/src/runtime/runtime.js +81 -100
- package/src/runtime/skins/caderno.css +33 -0
- package/src/runtime/skins/cartaz.css +27 -0
- package/src/runtime/skins/infografico.css +29 -0
- package/src/runtime/skins/lousa.css +22 -0
- package/src/runtime/skins/musgo.css +42 -0
- package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
- package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
- package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
- package/src/runtime/vendor/geo-contornos.json +1 -0
- package/src/runtime/vendor/leaflet.css +661 -0
- package/src/runtime/vendor/leaflet.js +6 -0
- package/src/runtime/vendor/maplibre-gl.css +1 -0
- package/src/runtime/vendor/maplibre-gl.js +825 -0
- package/src/runtime/vendor/maplibre-leaflet.js +234 -0
- package/src/runtime/vendor/maplibre-worker.js +10 -0
- package/src/studio/ai-chat-route.js +20 -0
- package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
- package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
- package/src/studio/command-approval.js +2 -3
- package/src/studio/demo-decks.js +6 -2
- package/src/studio/export-jobs.js +86 -0
- package/src/studio/exporting.js +36 -18
- package/src/studio/feature-demos.js +144 -0
- package/src/studio/geo-routes.js +138 -0
- package/src/studio/ia-routes.js +9 -0
- package/src/studio/layout-samples.js +78 -16
- package/src/studio/library-routes.js +0 -9
- package/src/studio/project.js +2 -1
- package/src/studio/public/ai-settings.js +6 -3
- package/src/studio/public/app.js +46 -52
- package/src/studio/public/explorer.js +23 -8
- package/src/studio/public/export-status.js +154 -0
- package/src/studio/public/index.html +17 -10
- package/src/studio/public/library.css +12 -3
- package/src/studio/public/library.html +3 -0
- package/src/studio/public/library.js +39 -12
- package/src/studio/public/map-data.js +158 -0
- package/src/studio/public/map-editor.js +676 -0
- package/src/studio/public/map-fields.js +21 -0
- package/src/studio/public/map-settings.js +86 -0
- package/src/studio/public/slide-form.js +19 -16
- package/src/studio/public/style.css +58 -0
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/public/viewers.js +2 -1
- package/src/studio/public/visual-editor.js +1 -1
- package/src/studio/server.js +34 -9
- package/src/studio/snapshot.js +8 -4
- package/src/studio/template-collections.js +1 -1
- package/src/studio/tour.js +158 -0
- package/src/themes.js +154 -0
- package/templates/cenario-e-ciencia.yaml +1 -21
- package/templates/ensaio-api.yaml +3 -3
- package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
- package/templates/cenario/imagens/cidade.svg +0 -30
- package/templates/cenario/imagens/pessoa.svg +0 -15
|
@@ -0,0 +1,676 @@
|
|
|
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.
|
|
11
|
+
window.SagaMapEditor = (() => {
|
|
12
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
13
|
+
const norm = (s) => String(s ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
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));
|
|
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; };
|
|
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();
|
|
20
|
+
const fmtLen = (m) => (m >= 1000 ? `${(m / 1000).toLocaleString("pt-BR", { maximumFractionDigits: 2 })} km` : `${Math.round(m)} m`);
|
|
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²`);
|
|
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
|
+
};
|
|
41
|
+
|
|
42
|
+
let S = null; // { root, mbox, map, entries, box, slide, commit, ctx, mode, sel, layer, hidden, draft, … }
|
|
43
|
+
|
|
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(); }
|
|
205
|
+
|
|
206
|
+
function setMode(m) {
|
|
207
|
+
S.mode = m;
|
|
208
|
+
S.joinFrom = null;
|
|
209
|
+
clearDraft();
|
|
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);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------- desenho (linha, área, régua)
|
|
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
|
+
}
|
|
342
|
+
async function addVertex(ll) {
|
|
343
|
+
const L = window.L;
|
|
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);
|
|
346
|
+
if (S.mode === "linha" && S.ruas && last) {
|
|
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; }
|
|
350
|
+
} else d.pts.push([ll.lat, ll.lng]);
|
|
351
|
+
d.marks.push(L.circleMarker(ll, { radius: 5, color: "#7c5cff", fillOpacity: 1 }).addTo(S.map));
|
|
352
|
+
d.layer.setLatLngs(d.pts);
|
|
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."}`);
|
|
356
|
+
}
|
|
357
|
+
async function finishShape() {
|
|
358
|
+
const d = S.draft;
|
|
359
|
+
if (!d) 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; }
|
|
362
|
+
const coords = d.pts.map(([lat, lon]) => [lon, lat]);
|
|
363
|
+
const geometry = S.mode === "area" ? { type: "Polygon", coordinates: [[...coords, coords[0]]] } : { type: "LineString", coordinates: coords };
|
|
364
|
+
clearDraft();
|
|
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
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
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; };
|
|
396
|
+
S.handles = ring.map(([lon, lat], i) => {
|
|
397
|
+
const h = L.marker([lat, lon], { draggable: true, icon: L.divIcon({ className: "me-vertex", iconSize: [14, 14] }) }).addTo(S.map);
|
|
398
|
+
h.on("dragend", () => { const q = h.getLatLng(); ring[i] = [q.lng, q.lat]; save(setRing); });
|
|
399
|
+
h.on("contextmenu", async (e) => {
|
|
400
|
+
L.DomEvent.stop(e);
|
|
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
|
+
});
|
|
408
|
+
});
|
|
409
|
+
return h;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
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();
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function menu(items) {
|
|
431
|
+
return new Promise((res) => {
|
|
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();
|
|
435
|
+
d.querySelectorAll("[data-i]").forEach((b) => (b.onclick = () => { res(b.dataset.i || null); d.close(); }));
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
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
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
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("_"));
|
|
479
|
+
}
|
|
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);
|
|
496
|
+
}
|
|
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 };
|
|
676
|
+
})();
|