sagadeck 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +40 -0
  2. package/docs/ANDAMENTO.md +122 -0
  3. package/docs/REFERENCIA.md +58 -0
  4. package/package.json +2 -1
  5. package/src/ai/deck-ai.js +13 -4
  6. package/src/ai/map-analysis-run.js +119 -0
  7. package/src/ai/map-data.js +221 -0
  8. package/src/build.js +6 -2
  9. package/src/export/browser.js +23 -1
  10. package/src/export/pptx.js +2 -1
  11. package/src/export/shots.js +3 -1
  12. package/src/layouts.js +10 -0
  13. package/src/map-analysis.js +106 -0
  14. package/src/map-config.js +83 -0
  15. package/src/map-routing.js +82 -0
  16. package/src/map-services.js +60 -0
  17. package/src/map.js +261 -0
  18. package/src/runtime/base.css +20 -0
  19. package/src/runtime/map.js +234 -0
  20. package/src/runtime/runtime.js +1 -1
  21. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  22. package/src/runtime/vendor/geo-contornos.json +1 -0
  23. package/src/runtime/vendor/leaflet.css +661 -0
  24. package/src/runtime/vendor/leaflet.js +6 -0
  25. package/src/studio/ai-chat-route.js +20 -0
  26. package/src/studio/demo-decks.js +3 -1
  27. package/src/studio/geo-routes.js +138 -0
  28. package/src/studio/layout-samples.js +71 -0
  29. package/src/studio/project.js +1 -1
  30. package/src/studio/public/ai-settings.js +4 -1
  31. package/src/studio/public/app.js +7 -3
  32. package/src/studio/public/explorer.js +23 -8
  33. package/src/studio/public/index.html +4 -0
  34. package/src/studio/public/library.html +2 -0
  35. package/src/studio/public/library.js +6 -1
  36. package/src/studio/public/map-data.js +154 -0
  37. package/src/studio/public/map-editor.js +287 -0
  38. package/src/studio/public/map-fields.js +21 -0
  39. package/src/studio/public/map-settings.js +84 -0
  40. package/src/studio/public/slide-form.js +1 -1
  41. package/src/studio/public/style.css +13 -0
  42. package/src/studio/public/ui-icons.js +1 -1
  43. package/src/studio/public/viewers.js +2 -1
  44. package/src/studio/public/visual-editor.js +1 -1
  45. package/src/studio/server.js +10 -0
  46. package/src/studio/tour.js +76 -0
package/README.md CHANGED
@@ -150,6 +150,46 @@ ilustrar" e `--no-images` proíbe. Imagens só são geradas para os slides que a
150
150
 
151
151
  Todo YAML vindo do LLM é validado (renderiza cada slide); se falhar, o erro volta para o LLM corrigir (até 3 tentativas). Decks gerados passam por uma rodada de enxugamento quando o fiscal anti-sono reclamaria.
152
152
 
153
+ ## Mapa (opcional)
154
+
155
+ O slide de mapa (`layout: map`) mostra pontos, linhas e áreas sobre um mapa ao vivo: sensores, medidores, unidades de
156
+ atendimento, cinemas, trajetos, bairros, o que for. Os dados vêm de uma planilha do projeto (CSV com latitude e
157
+ longitude, ou só com o endereço) ou de arquivos GeoJSON, GPX e KML. **Nada aqui é obrigatório**: quem não usa mapa
158
+ não precisa configurar coisa nenhuma, e o sagadeck não pede nada à internet por causa dele.
159
+
160
+ **Sem chave nenhuma, já funciona** com os serviços públicos gratuitos do OpenStreetMap:
161
+
162
+ | serviço | para quê | padrão (sem configurar) |
163
+ |---|---|---|
164
+ | mapa de fundo | as ruas por baixo das camadas | OpenStreetMap |
165
+ | endereço e coordenada | **Achar coordenadas** de uma planilha só com endereço | Nominatim (1 endereço por segundo) |
166
+ | busca de lugares | achar cinemas, escolas, unidades de saúde… pelo nome do tipo | Overpass |
167
+ | rotas | "seguir as ruas" ao desenhar, distância e tempo | OSRM (servidor de demonstração) |
168
+
169
+ **Chave opcional: OpenRouteService**, para rotas a pé, de carro e de bicicleta e para a **área alcançável em X
170
+ minutos**. É gratuita:
171
+ 1. Crie a conta em [openrouteservice.org](https://openrouteservice.org) e confirme o e-mail.
172
+ 2. No painel, gere um token do plano gratuito (Standard).
173
+ 3. No Studio, **Configurar mapa** (no alto da biblioteca, ou no formulário do slide de mapa) › Rotas › **Usar o
174
+ OpenRouteService**, cole a chave e clique em **Testar**.
175
+
176
+ A configuração fica só nesta máquina, em `~/.sagadeck/mapa.json` (como a da IA); a chave nunca volta inteira para a
177
+ página e nunca vai para dentro da apresentação. Também vale pôr a chave numa variável de ambiente e indicar o nome dela
178
+ em `keyEnv` (no servidor, por exemplo):
179
+
180
+ ```json
181
+ { "rotas": { "provedor": "openrouteservice", "url": "https://api.openrouteservice.org", "keyEnv": "OPENROUTESERVICE_API_KEY" } }
182
+ ```
183
+
184
+ Os serviços públicos têm regras de uso que o sagadeck respeita sozinho: identifica cada pedido, mostra a atribuição
185
+ "© colaboradores do OpenStreetMap", busca um endereço por segundo, guarda o que já achou e nunca baixa mapa fora da
186
+ tela. Eles são para uso leve e não comercial; no trabalho, prefira o servidor de mapas da empresa ou um provedor com
187
+ chave (os endereços se trocam em **Configurar mapa**). Antes de mandar os endereços de uma planilha sua para um
188
+ serviço de fora, o sagadeck pergunta.
189
+
190
+ Se a rede barrar um serviço (autorização negada, proxy, bloqueio), o sagadeck faz **uma tentativa e para**: avisa
191
+ na tela, as camadas continuam desenhadas e nada é tentado de novo até você clicar em **Tentar de novo**.
192
+
153
193
  ## Desenvolvimento
154
194
 
155
195
  ```bash
package/docs/ANDAMENTO.md CHANGED
@@ -42,6 +42,128 @@ datadas abaixo). O resto fica para depois e só entra se for pedido. Antes de co
42
42
  ler o conteúdo força o download. Conferir se listar a biblioteca (`openLibrary`, `/api/library`) lê o YAML de
43
43
  cada deck; se lê, listar só pelo nome/metadado e abrir o conteúdo sob demanda.
44
44
 
45
+ ## Frente: mapa (planejada em 10/10/2026, em andamento)
46
+
47
+ Pedido de Narumi, depois de muita conversa. **Leia antes de mexer.** Recurso de nicho, mas completo: mostrar e
48
+ explicar algo geográfico (pontos de interesse de qualquer tipo, linhas, áreas), com a IA montando a partir de dados.
49
+ Não é um QGIS: análise pesada fica lá; aqui é apresentar bem, com contas simples feitas pelo código.
50
+
51
+ **Decisões (não reabrir):**
52
+ - **Internet não é limitação.** Recurso online é bem-vindo e fica disponível onde funciona; onde não funciona, some ou
53
+ avisa. Nada de "assar/chapar" o mapa de fundo no deck (ela odiou): mapa **ao vivo** (Leaflet, embutido no pacote).
54
+ - **Nada bancário nem de setor nenhum**: pontos de interesse genéricos (sensor, hidrômetro, placa solar, cinema,
55
+ órgão público…). Exemplos, REFERENCIA e testes com casos variados.
56
+ - **A planilha é a fonte, o mapa é a vista**: pontos em CSV do projeto (`contexto/*.csv`, importa XLSX); linhas e
57
+ polígonos em GeoJSON do projeto (importa GPX/KML). O slide diz qual arquivo e quais colunas (posição, cor, tamanho,
58
+ rótulo). A mesma planilha serve tabela e gráfico.
59
+ - **Carregamento preguiçoso** em quatro níveis: o Leaflet só entra no HTML de deck com mapa; o mapa só liga (e só pede
60
+ tiles) quando o slide aparece; o editor de mapa no Studio vem por import dinâmico; os módulos de serviço no servidor
61
+ só no primeiro pedido. Teste para os três primeiros.
62
+ - **Uma tentativa e para**: erro de autorização ou bloqueio (401, 403, 407, recusa, certificado, até 429) marca o
63
+ serviço indisponível na sessão; só volta com "Tentar de novo" ou reabrindo o Studio. Tiles: se os primeiros falham
64
+ sem nenhum sucesso, a camada de fundo desliga e aparece aviso (camadas continuam). **Sem teste proativo** de
65
+ serviço: o primeiro uso real é o teste. Teste: serviço com 403 recebe exatamente um pedido.
66
+ - **Pedidos saem do navegador no Studio local** (mesmo caminho/proxy do Google Maps, que abre na rede do trabalho; o
67
+ Node não usa o proxy do sistema). No multiusuário, do servidor (a chave não vai para a página dos outros).
68
+ - **Serviços padrão gratuitos**, todos configuráveis em **Configurar mapa** (`~/.sagadeck/mapa.json`, como o ia.json):
69
+ tiles do OpenStreetMap, Nominatim (endereço↔coordenada), Overpass (busca de lugares/linhas/áreas), OSRM
70
+ (rotas, encaixe de GPS) e OpenRouteService (rotas e isócronas; chave da Narumi já está no mapa.json desta máquina,
71
+ nunca no repositório). Respeitar as políticas: atribuição visível "© colaboradores do OpenStreetMap", identificação
72
+ do sagadeck, Nominatim 1/s e cache obrigatório (lote grande: avisar e sugerir provedor com chave), nada de baixar
73
+ tiles fora da tela. Públicos de OSRM/Overpass são não comerciais: para uso no trabalho, servidor da empresa ou chave.
74
+ - **A IA**: recebe no prompt o estado de cada serviço (funcionando, não usado, bloqueado) e decide; o código recusa
75
+ sem pedido de rede o que está bloqueado. Pergunta antes de mandar **dados da pessoa** a serviço de fora (primeira vez
76
+ por planilha) e antes de lote grande (tempo estimado). Geometria nunca inventada: vem de dados, do OSM ou do cálculo.
77
+ Testes: mock (encanamento) e ao vivo (decisão).
78
+ - PDF/PPTX capturam o mapa como está na tela na exportação; camada por clique vira animação no PPTX.
79
+
80
+ **Entregas (uma branch/PR cada, com testes e CI verde):**
81
+ 1. **Feita (10/10).** Slide `map`: motor `src/map.js` (CSV com colunas de posição achadas pelo nome, GeoJSON, GPX,
82
+ KML, `areas` por UF/país com os contornos embutidos `src/runtime/vendor/geo-contornos.json`; cor por categoria
83
+ na paleta do tema `c1…c5` ou escala por número, tamanho, rótulo, cartão; prévia SVG para miniaturas), mapa ao vivo
84
+ `src/runtime/map.js` (Leaflet 1.9.4 embutido por `scripts/vendor-map.mjs`, só em deck com mapa; monta só no slide
85
+ atual; testa **um** tile antes de ligar o fundo e, se falhar, nenhum outro mapa da sessão tenta; legenda que liga e
86
+ desliga; camada por clique com marcadores `data-step` e câmera por camada), configuração da máquina
87
+ `src/map-config.js` (`~/.sagadeck/mapa.json`; só o fundo vai para o deck), exportação abrindo o deck com mapa por
88
+ endereço local (Referer) e esperando o mapa de cada slide (`settleSlide`), formulário `map-fields.js`, REFERENCIA,
89
+ exemplo de layout (entra no Conheça o SagaDeck). Testes: `test/map.test.js` (motor, apresentação com servidor de
90
+ tiles falso, 403 recebe exatamente um pedido, exportação) e `studio.test.js` (inserir mapa, monta, trocar fundo
91
+ salva). Achados no caminho: coordenada "-46.702" lida como milhar pelo leitor de números (agora `coordNum`); a
92
+ classe `widget` na caixa fazia o runtime trocar o conteúdo; rota do `map.js` com cache servia código velho.
93
+ Pendente para depois: geocodificar `at: "Recife"` (entrega 2) e contornos de reserva quando o fundo falha.
94
+ 2. **Feita (10/10).** A tela de dados é a planilha da aba Arquivos (`sheetGrid`, já editável e colando do Excel),
95
+ com as ferramentas de mapa (`src/studio/public/map-data.js`): **Pôr no mapa** (adivinha cor pela coluna de
96
+ categorias que se repetem e rótulo pela de nome; `.xlsx` ganha um CSV ao lado), **Achar coordenadas** (pergunta o
97
+ plano antes e mostra quantos endereços, para qual serviço e quanto tempo; só envia com o sim; progresso; o que não
98
+ achou fica listado; cache em `.sagadeck/geocodificacao.json`, consentimento em `.sagadeck/mapa-consentimento.json`)
99
+ e a **seleção ligada** (clicar no ponto seleciona a linha; selecionar a linha abre o cartão). Servidor:
100
+ `src/map-services.js` (pedidos identificados `sagadeck/versão`, uma tentativa e para, estado de cada serviço) e
101
+ `src/studio/geo-routes.js` (`/api/mapa`, `/api/mapa/tentar`, `/api/mapa/geocode` em ndjson). Tela **Configurar
102
+ mapa** (`map-settings.js`, botão no formulário do mapa). Decisão: a busca de endereço sai do servidor do Studio
103
+ (a política do Nominatim pede identificação, que o navegador não deixa mudar); os tiles saem do navegador.
104
+ Testes: `test/map-data.test.js`. Fica para depois: abrir GeoJSON como tabela.
105
+ 3. **Feita (10/10).** Editor `src/studio/public/map-editor.js`, carregado só no clique em **Editar no mapa** (botão e
106
+ barra no `#canvas-viewport`, fora do slide escalado): Mover (arrastar ponto atualiza a linha da planilha; clicar
107
+ numa linha/área abre os vértices: arrastar, apagar, dividir; juntar linhas pelas pontas mais próximas), Ponto
108
+ (linha nova na planilha da camada, endereço pelo `/reverse`), Linha e Área (GeoJSON da camada ou
109
+ `contexto/desenhos.geojson`; "Seguir as ruas" a pé/carro/bicicleta pelo `/api/mapa/rota`), Régua, Usar esta vista
110
+ (grava `view` no slide); comprimento e área ao vivo. O Studio guarda a vista entre redesenhos (`keepView`). Rotas,
111
+ endereço de um ponto e área alcançável em `src/map-routing.js` (OpenRouteService com chave, testado ao vivo com a
112
+ chave da Narumi: 817 m a pé, isócrona de 5 min; ou OSRM). Chave do mapa como a da IA: tela Configurar mapa com
113
+ "Usar o OpenRouteService" e Testar, botão na biblioteca, `keyEnv` (variável de ambiente, para o servidor). README
114
+ com a seção "Mapa (opcional)". Achados: o editor visual capturava o clique no mapa (agora ignora `.map-live`); o
115
+ projeto não gravava `.geojson/.gpx/.kml` como texto. Testes: `test/map-editor.test.js` e `map-data.test.js`.
116
+ Retomada: PR #178 mergeado após CI verde no Ubuntu e no Windows. A corrida do arrastar passou; a falha restante era
117
+ uma releitura ao clicar na aba de planilha já ativa, que substituía a grade e apagava a seleção feita pelo mapa.
118
+ O clique agora mantém a aba ativa. Regressão com resposta atrasada reproduziu a falha antes da correção e passou
119
+ depois; mapa/planilha e editor: 6 testes passando. Suíte completa: 950 passaram, 22 pulados, zero falhas.
120
+ 4. **Feita (10/10).** A IA que edita o deck pede dados com um bloco `mapa:` (como o `ver:` do material; nenhuma
121
+ chamada a mais quando o pedido não é de mapa): `lugares` e `linhas` (Overpass, com o filtro de tags que ela
122
+ escolhe e o centro achado no Nominatim), `contorno` (Nominatim com polígono), `web` (a pesquisa existente + uma
123
+ extração que só usa o que leu, com a URL de cada linha; as coordenadas vêm depois) e `coordenadas` (planilha da
124
+ pessoa; sem `confirmado: true`, o código pergunta "Posso enviar?" com opções e não envia nada). O Studio executa
125
+ (`src/ai/map-data.js`), grava em `contexto/` com a coluna `fonte` e chama a IA de novo (até 2 rodadas). O prompt
126
+ (`mapDataPrompt`) leva o estado de cada serviço; bloqueado não recebe pedido e a IA fica sabendo. Testes:
127
+ `test/map-ai.test.js` (encanamento, LLM falso) e 3 ao vivo em `test/ai-live.test.js` (pede `amenity=cinema` perto
128
+ de Campinas; não se dá o sim sozinha; com a busca bloqueada, não pede e oferece alternativa), todos passando com o
129
+ modelo de verdade. Conferido com o OpenStreetMap real: 5 cinemas num raio de 8 km do centro de Campinas (o
130
+ Overpass público às vezes responde 504 por sobrecarga; o sagadeck registra e não insiste).
131
+ 5. **Feita (10/10), no mesmo PR da 4.** Contas puras em `src/map-analysis.js` (haversine, mais próximo, raio, ponto em
132
+ polígono com buraco, distância a linha, círculo, faixa em volta de linha para desenhar, "onde instalar" por grade e
133
+ escolha gulosa) e as ações do bloco `mapa:` em `src/ai/map-analysis-run.js`: `mais_proximo` (CSV com
134
+ `mais_proximo` e `distancia_m`), `contar` (por área ou raio, grava o círculo), `faixa` (GeoJSON da faixa e quantos
135
+ pontos dentro, pela distância exata à linha), `sugerir` (CSV de sugestões, avisando que é geométrico), `rota`
136
+ (distância e tempo), `alcance` (isócronas, só com OpenRouteService) e `encaixar` (OSRM `/match`, amostrando até 100
137
+ pontos). A IA só narra os números. Achado: pontos todos alinhados deixavam o "onde instalar" sem resposta (retângulo
138
+ de altura zero). Testes: `test/map-analysis.test.js` e o ao vivo "qual loja fica mais perto de cada pedido" (pede
139
+ `mais_proximo`).
140
+
141
+ **Publicação 1.8.0 (retomada em 10/10):** versão preparada em `package.json` e lockfile, incluindo as cinco entregas
142
+ e o Conheça o SagaDeck. O #178 já foi mergeado; falta concluir a CI e mergear o #179, conferir a CI da `main`, criar a
143
+ release e atualizar a instalação local e o Oracle. No Oracle, o ambiente e os dois `mapa.json` já estão preparados
144
+ com `OPENROUTESERVICE_API_KEY` (modo 600, dono de cada serviço); os serviços aguardam o código da release para
145
+ reiniciar. Os quatro testes de mapa com o modelo real passaram novamente. Suíte completa da 1.8.0: 982 testes,
146
+ 956 passaram, 26 pulados, zero falhas; mapa/editor/exportação/leitores e regras: 22 testes direcionados passaram.
147
+ Na revisão com cliques reais, a planilha cobria o mapa inteiro e a legenda interceptava um ponto no enquadramento
148
+ automático. Corrigidos: a planilha fica ao lado do mapa (sugestões de gráfico recolhidas, com botão para abrir), a
149
+ seleção funciona nas duas direções e o enquadramento reserva espaço para a legenda. O teste reproduziu a obstrução
150
+ antes da correção e agora clica no ponto e na célula, sem disparar eventos sintéticos.
151
+
152
+ ## Conheça o SagaDeck — 10/10/2026
153
+
154
+ Pergunta de Narumi: a pessoa nem sabe o que dá para gerar. Agora a biblioteca tem, na lateral, **Conheça o
155
+ SagaDeck** (também na vitrine, em Recursos do SagaDeck): uma apresentação com um slide por recurso, agrupada em
156
+ seções, que abre em prévia (nada é gravado; "Usar como base" cria a cópia em Modelos).
157
+
158
+ - `src/studio/tour.js` monta-se sozinha dos exemplos de layout (`LAYOUT_SAMPLES`): layout novo entra sem ninguém
159
+ lembrar; layout fora dos grupos cai em "Mais recursos". Fecha com o que não é layout (criar e conversar com a IA,
160
+ leitura crítica, importar PowerPoint, gráfico da planilha, temas, apresentador, PDF/PowerPoint, link).
161
+ - Cada slide traz nas **notas** (visíveis abaixo do slide no Studio e na visão do apresentador) o nome, a descrição e
162
+ a frase para pedir à IA (`LAYOUT_ASK` em `layout-samples.js`, exemplos de áreas variadas); no rodapé (`source`)
163
+ também, nos layouts que o desenham. Os links de navegação dos exemplos saem (apontariam para slides inexistentes).
164
+ - Testes: `engine.test.js` (todo layout tem pedido e aparece no tour, com nome e pedido; renderiza) e
165
+ `library-ui.test.js` (a lateral abre em prévia e nada é criado na biblioteca).
166
+
45
167
  ## Sem citar o programa de apresentações da maçã (1.7.1) — 10/10/2026
46
168
 
47
169
  Pedido de Narumi: o código e a documentação não citam o programa de apresentações da maçã, a empresa nem os
@@ -96,6 +96,7 @@ Tabela em markdown no meio de um texto (`problem`, `body`, `text`, passo do `sol
96
96
  | `timeline` | `title, events: [{when, title, text, tag}], highlight, after, build` | linha do tempo |
97
97
  | `chart` | `title, chart: {…}, side (texto ou elemento), chartHeight` | gráfico + comentário |
98
98
  | `table` | `title, head, rows, style, color, highlight, total, side, caption, source` | tabela de verdade nas cores do tema |
99
+ | `map` | `kicker, title, basemap, layers: [{name, points \| geojson \| gpx \| kml \| areas, color, size, label, colors, popup, step, view}], build, view, caption` | **mapa ao vivo** com pontos, linhas e áreas (sensores, medidores, unidades de atendimento, cinemas, trajetos, regiões…): os dados vêm da planilha do projeto ou de arquivos GeoJSON/GPX/KML; ver [Mapa](#mapa-map) |
99
100
  | `compare` | `title, left: {label, value, title, text, items, figure, hl}, right: {…}, vs, after, build` | A × B |
100
101
  | `matrix` | `title, x: [esq, dir], y: [cima, baixo], cells: [4 × {title, text, example, hl}], build` | matriz 2×2 |
101
102
  | `question` | `question, options: [texto ou {key, text, sub}], keys, cols, timer, hint, optionSize` | pergunta para a plateia (com timer) |
@@ -665,6 +666,63 @@ Como o slide mestre do PowerPoint: `master:` no deck desenha em todo slide o que
665
666
 
666
667
  Quando melhorar ou reescrever uma apresentação que já existia (principalmente uma importada), marque cada slide que mudou para a pessoa validar: `review: { status: alterado, note: "o que mudou, em uma frase", original: 7 }` (`original` = o número do slide no original) ou `review: { status: novo, note: "por que entrou" }` para slide que não existia. O Studio mostra um selo na miniatura, uma faixa no slide com **Ver original**, **Aceitar** e **Desfazer** (volta ao slide de `original/original.yaml`; slide novo sai) e a lista em Revisar › **Mudanças**. A transformação da apresentação inteira ainda usa dois estados: `pendente` (a proposta ficou ao lado do original porque faltava algo dele; aceitar tira o original, desfazer tira a proposta) e `revisar` (a conferência pela foto achou um problema de desenho). A apresentação e a exportação não mostram as marcas. Nunca apague dados do original: números, nomes, fórmulas, fontes e figuras específicas (um mapa, um experimento de uma cidade) continuam, só mais claros.
667
668
 
669
+ ## Mapa (`map`)
670
+
671
+ Mapa ao vivo (ruas por baixo, arrastável e com zoom ao apresentar) com **camadas** por cima. A planilha é a fonte e o
672
+ mapa é a vista: os pontos vêm de um CSV do projeto (normalmente em `contexto/`), e as linhas e áreas de GeoJSON, GPX
673
+ (rastro de GPS) ou KML. Serve para qualquer ponto de interesse (sensor, medidor, placa solar, unidade de atendimento,
674
+ escola, cinema…) e qualquer traçado ou área (rede, trajeto, bairro, área de obra).
675
+
676
+ ```yaml
677
+ - layout: map
678
+ kicker: Monitoramento
679
+ title: Onde estão os ==sensores==
680
+ basemap: claro # ruas (padrão) | claro | escuro | satelite (se configurado) | nenhum
681
+ build: true # uma camada por clique
682
+ layers:
683
+ - name: Sensores # nome na legenda
684
+ points: contexto/sensores.csv # colunas de latitude e longitude achadas pelo nome (lat/latitude, lon/longitude)
685
+ color: status # coluna: categoria vira cor (legenda clicável); número vira escala
686
+ colors: { ativo: s3, sem sinal: alert } # cor de cada categoria (opcional)
687
+ size: vazao # coluna numérica vira o tamanho do ponto (opcional)
688
+ label: nome # rótulo fixo ao lado de cada ponto (até 60 pontos)
689
+ popup: [nome, status, vazao] # campos do cartão ao clicar (padrão: as primeiras colunas)
690
+ - name: Rede de água
691
+ geojson: contexto/rede.geojson # linhas e polígonos; ou gpx: / kml:
692
+ color: material
693
+ view: fit # quando a camada aparece, a câmera enquadra ela
694
+ - name: Consumo por estado
695
+ areas: { SP: 320, RJ: 140, MG: 150 } # siglas de UF ou códigos de país (BR, PT…): pinta pela escala
696
+ legend: mil m³
697
+ ```
698
+
699
+ - `points:` também aceita a lista no próprio slide: `[{ nome: Centro, lat: -23.55, lon: -46.63, status: ativo }]`.
700
+ Uma coluna só com "lat, lon" vale (`coordenadas`, `posicao`). Use ponto ou vírgula decimal; nunca invente
701
+ coordenadas: elas vêm da planilha, do arquivo ou de uma busca. Linha sem posição válida fica de fora (o fiscal avisa).
702
+ - `color:` aceita também uma cor fixa (`s1`, `em`, `hi`, `alert`, `#1F6FB2`). `step` (número) diz em que clique a
703
+ camada aparece; `view` da camada (`fit` ou `{ center: [lat, lon], zoom }`) leva a câmera até ela nesse clique.
704
+ - `view:` do slide fixa o enquadramento (`{ center: [-23.55, -46.63], zoom: 13 }`); sem ele, o mapa enquadra os dados.
705
+ - Planilha só com endereço (sem latitude e longitude): no Studio, a planilha aberta (aba Arquivos) tem **Achar
706
+ coordenadas**, que pergunta antes de mandar os endereços para o serviço de fora e grava as colunas `latitude` e
707
+ `longitude`; **Pôr no mapa** cria o slide a partir dela. Sugira esses botões em vez de inventar coordenadas.
708
+ Com um slide de mapa selecionado, a planilha fica ao lado dele: clicar num ponto seleciona a linha e selecionar
709
+ uma linha abre o cartão do ponto. O enquadramento automático deixa os pontos livres da legenda.
710
+ - **Editar no mapa** (botão na prévia do slide, no Studio): a pessoa clica para criar pontos (vão para a planilha da
711
+ camada, com o endereço preenchido), desenha linhas e áreas (vão para o GeoJSON da camada; "Seguir as ruas" liga os
712
+ cliques pelo serviço de rotas), arrasta pontos e vértices, divide e junta linhas, mede com a régua e grava o
713
+ enquadramento ("Usar esta vista"). Quando a pessoa quiser marcar algo à mão no mapa, indique esse botão.
714
+ - **Dados de fora** (lugares de um tipo, linhas como ciclovias, o contorno de um lugar com nome, uma lista da web,
715
+ coordenadas da planilha da pessoa): peça com o bloco `mapa:` (as regras vêm no prompt); o Studio busca, grava os
716
+ arquivos em `contexto/` com a fonte de cada linha e te devolve o que gravou. Nunca invente coordenada nem contorno.
717
+ - **Análises** (o mais próximo e a distância, quantos num raio ou numa área, faixa em volta de uma linha, onde
718
+ instalar o próximo, rota com tempo, área alcançável em X minutos, rastro de GPS encaixado nas ruas): também pelo
719
+ bloco `mapa:`; o Studio calcula e devolve os números. Nunca calcule distância, contagem ou tempo de cabeça.
720
+ - Serviços (Configurar mapa, opcional): sem chave, tudo funciona com os serviços públicos do OpenStreetMap; a chave
721
+ gratuita do OpenRouteService só acrescenta rotas a pé/de carro/de bicicleta confiáveis e área alcançável.
722
+ - Ao apresentar: clicar num ponto, linha ou área abre o cartão com os dados; clicar na legenda liga e desliga uma
723
+ categoria ou a camada inteira. O fundo vem da internet; sem rede, aparece um aviso e as camadas continuam. No PDF e
724
+ no PowerPoint o mapa sai como estava na tela.
725
+
668
726
  ## Tabelas (`table`)
669
727
 
670
728
  Tabela é tabela, nunca imagem: dados de um livro ou de um PowerPoint que vieram como figura viram `table` quando os valores estão legíveis (o sagadeck alinha os números à direita, com algarismos de mesma largura, e pinta nas cores do tema). Layout `table` (o slide inteiro) ou o elemento `{ table: {…} }` em qualquer lugar (`content`, `add`, `canvas`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sagadeck",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Gerador de apresentações: YAML -> HTML animado + PowerPoint editável, com temas e figuras geradas na hora.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "@material-symbols/svg-400": "^0.47.5",
34
34
  "esbuild": "^0.28.2",
35
35
  "jsqr": "^1.4.0",
36
+ "leaflet": "1.9.4",
36
37
  "mermaid": "^11.17.2"
37
38
  },
38
39
  "scripts": {
package/src/ai/deck-ai.js CHANGED
@@ -782,7 +782,7 @@ export function applyPatch(base, patch) {
782
782
  const STUDIO_KEYS = ["layout", "notes", "time", "id", "uid", "review", "original", "master", "visualEdits", "auto", "fiscalOk", "from", "image_prompt", "image_ref", "density", "deco", "theme", "palette", "tone",
783
783
  "bg", "fg", "background", "backgroundStyle", "footer", "header", "transition", "steps", "markStyle", "maxWords", "fit", "titleAs", "context"];
784
784
  const FREE_TEXT_KEYS = new Set(["code", "mermaid", "svg", "html", "request", "realtime", "body", "headers", "response", "notes", "consulta", "output", "json"]);
785
- const PATCH_WORDS = new Set(["slides", "insert", "delete", "edit", "deck", "variants", "test", "ver"]);
785
+ const PATCH_WORDS = new Set(["slides", "insert", "delete", "edit", "deck", "variants", "test", "ver", "mapa"]);
786
786
  let knownKeysCache = null;
787
787
  function knownSlideKeys() {
788
788
  if (knownKeysCache) return knownKeysCache;
@@ -925,6 +925,13 @@ function parseEditText(text, base) {
925
925
  const raw = parseYaml(yaml);
926
926
  if (!raw || typeof raw !== "object") throw new Error("O bloco yaml precisa ser um objeto com deck/slides/insert/delete (ou variants).");
927
927
  if (raw.variants) return parseVariants(raw.variants, base, prose);
928
+ // mapa: [ações] → a IA precisa de dados geográficos (lugares, linhas, contornos, web, coordenadas); quem busca é o
929
+ // Studio (src/ai/map-data.js), que chama de novo com o que gravou
930
+ if (raw.mapa) {
931
+ const acoes = [].concat(raw.mapa?.acoes || raw.mapa).filter((a) => a && typeof a === "object" && a.tipo);
932
+ if (!acoes.length) throw new Error("mapa: mande a lista de ações (tipo: lugares | linhas | contorno | web | coordenadas).");
933
+ return { spec: base, prose, changed: [], geo: acoes.slice(0, 4) };
934
+ }
928
935
  // ver: [arquivos] → a IA quer OLHAR o material (recorte, página inteira) antes de responder; quem mostra é o Studio
929
936
  if (raw.ver || raw.look) {
930
937
  const files = [].concat(raw.ver || raw.look).map((f) => String(f || "").trim()).filter(Boolean);
@@ -974,7 +981,7 @@ function commandEnvNames(apiContext) {
974
981
 
975
982
  // Chat lateral do Studio: aplica um pedido em linguagem natural ao deck.
976
983
  export async function editDeck({ spec, instruction, targetSlide = null, issues = [], images = false, imageOptions = {}, history = [], onProgress,
977
- visuals = [], renderNotes = [], apiContext = null, drawCheck = null, runCommand = null, materials = [], deferImages = false, maxImages, styles = null, signal, reviewCheck = null, reviewDepth = 0, repairSlides = null }) {
984
+ visuals = [], renderNotes = [], apiContext = null, drawCheck = null, runCommand = null, materials = [], deferImages = false, maxImages, styles = null, signal, reviewCheck = null, reviewDepth = 0, repairSlides = null, mapData = null }) {
978
985
  const deck = promptSpec(spec);
979
986
  const { slides: _slides, ...numbered } = deck;
980
987
  const slidesYaml = deck.slides.map((s, i) => `# ── slide ${i + 1} ──\n${YAML.stringify([s], { indent: 2 })}`).join("");
@@ -1012,7 +1019,7 @@ Antes de responder, verifique (e siga as Regras de edição):
1012
1019
  : text;
1013
1020
  const convo = conversationFor(history);
1014
1021
  const messages = [
1015
- { role: "system", content: `${systemPrompt({ images, ...(maxImages ? { maxImages } : {}) })}\n\n${AUTOMATION_NOTES}\n\n${EDIT_RULES}\n\n${CONVERSATION_RULES}\n\n${PATCH_FORMAT}\n\n${VARIANTS_FORMAT}\n\n${API_RULES}\n\n${runCommand ? runCommand.description || `${COMMAND_RULES}\n${VIDEO_COMMAND_RULES}${commandEnvNames(apiContext)}` : "Comandos: indisponíveis aqui (só no Studio local, com a apresentação salva na biblioteca). Não peça run:."}` },
1022
+ { role: "system", content: `${systemPrompt({ images, ...(maxImages ? { maxImages } : {}) })}\n\n${AUTOMATION_NOTES}\n\n${EDIT_RULES}\n\n${CONVERSATION_RULES}\n\n${PATCH_FORMAT}\n\n${VARIANTS_FORMAT}\n\n${API_RULES}\n\n${runCommand ? runCommand.description || `${COMMAND_RULES}\n${VIDEO_COMMAND_RULES}${commandEnvNames(apiContext)}` : "Comandos: indisponíveis aqui (só no Studio local, com a apresentação salva na biblioteca). Não peça run:."}${mapData ? `\n\n${mapData}` : ""}` },
1016
1023
  // a conversa deste deck: o que a pessoa disse lá atrás (compactado) + as últimas trocas inteiras
1017
1024
  ...(convo.memory ? [{ role: "user", content: convo.memory }, { role: "assistant", content: "Certo, levo isso em conta." }] : []),
1018
1025
  ...convo.recent,
@@ -1020,7 +1027,7 @@ Antes de responder, verifique (e siga as Regras de edição):
1020
1027
  ];
1021
1028
  let motifObjected = false;
1022
1029
  const drawWarned = new Set();
1023
- const { spec: edited, prose, attempts, changed = [], talk, options = [], variants, imagesDropped, visionRouted, test = [], commands = [], transform, style, review, look } =
1030
+ const { spec: edited, prose, attempts, changed = [], talk, options = [], variants, imagesDropped, visionRouted, test = [], commands = [], transform, style, review, look, geo } =
1024
1031
  await askUntilValid(messages, async (t) => {
1025
1032
  const parsed = parseEditText(t, spec);
1026
1033
  if (repairSlides && (parsed.spec.slides.length !== spec.slides.length || parsed.changed?.some(i => !repairSlides.includes(i)))) throw new Error('A revisão só pode corrigir os slides indicados, sem inserir, excluir ou alterar outros slides.');
@@ -1043,6 +1050,8 @@ Antes de responder, verifique (e siga as Regras de edição):
1043
1050
  if (transform) return { reply: prose || "Vou transformar a apresentação.", spec, actions, targetSlide, transform };
1044
1051
  // quer ver o material antes de responder: o Studio mostra as imagens e chama de novo (src/studio/ai-chat-route.js)
1045
1052
  if (look) return { reply: prose || "Vou olhar o material.", spec, actions, targetSlide, look };
1053
+ // precisa de dados para o mapa: o Studio busca e chama de novo (src/studio/ai-chat-route.js)
1054
+ if (geo) return { reply: prose || "Vou buscar os dados para o mapa.", spec, actions, targetSlide, geo };
1046
1055
  // estilo (Brand Kit): quem aplica/salva é o servidor, com a biblioteca
1047
1056
  if (style) return { reply: prose || "Certo.", spec, actions, targetSlide, style };
1048
1057
  // versões para escolher: nada muda até a pessoa escolher uma
@@ -0,0 +1,119 @@
1
+ // Análises do mapa pedidas pela IA no bloco `mapa:` (entrega 5): o código calcula, grava o resultado no projeto e
2
+ // devolve números para a IA narrar. mais_proximo, contar (em raio ou em área), faixa em volta de uma linha, sugerir
3
+ // onde instalar, rota com tempo, área alcançável e encaixar um rastro de GPS nas ruas. Lê as camadas do projeto (CSV
4
+ // de pontos, GeoJSON, GPX, KML). Quem chama é runMapData (src/ai/map-data.js), que já cuidou do serviço bloqueado.
5
+ import fs from "node:fs";
6
+ import * as Project from "../studio/project.js";
7
+ import { parseCSV } from "../csv.js";
8
+ import { pointsFromRows, fromGPX, fromKML } from "../map.js";
9
+ import { geocodeOne } from "../map-services.js";
10
+ import { nearest, withinRadius, inPolygon, pointLine, circle, lineBuffer, suggestSites, fmtMetros } from "../map-analysis.js";
11
+ import { route, isochrone, matchTrace, MODOS } from "../map-routing.js";
12
+
13
+ export const ANALYSES = new Set(["mais_proximo", "contar", "faixa", "sugerir", "rota", "alcance", "encaixar"]);
14
+
15
+ const okFile = (f, ext) => typeof f === "string" && /^contexto\/[\w\-. /]+$/.test(f) && f.toLowerCase().endsWith(ext) && !f.includes("..");
16
+ const nameOf = (props, i) => props?.nome || props?.name || props?.titulo || `item ${i + 1}`;
17
+ const fc = (features) => JSON.stringify({ type: "FeatureCollection", features });
18
+
19
+ async function readPoints(P, file) {
20
+ const { rows } = parseCSV(fs.readFileSync(Project.resolveIn(P, file), "utf8"));
21
+ const head = (rows[0] || []).map((h) => String(h).trim());
22
+ return pointsFromRows(head, rows.slice(1)).features.map((f) => ({ lat: f.geometry.coordinates[1], lon: f.geometry.coordinates[0], props: f.properties }));
23
+ }
24
+ function readShapes(P, file) {
25
+ const text = fs.readFileSync(Project.resolveIn(P, file), "utf8");
26
+ if (/\.gpx$/i.test(file)) return fromGPX(text);
27
+ if (/\.kml$/i.test(file)) return fromKML(text);
28
+ const j = JSON.parse(text);
29
+ return j.type === "FeatureCollection" ? j.features : j.type === "Feature" ? [j] : [{ type: "Feature", properties: {}, geometry: j }];
30
+ }
31
+ async function placeOf(x, cfg) {
32
+ if (Array.isArray(x) && x.length === 2) return [Number(x[0]), Number(x[1])];
33
+ const hit = await geocodeOne(String(x), { cfg });
34
+ if (!hit) throw new Error(`não achei "${x}" no mapa`);
35
+ return [hit.lat, hit.lon];
36
+ }
37
+
38
+ export async function runAnalysis(a, { P, cfg, onProgress = () => {} }) {
39
+ if (a.tipo === "mais_proximo") {
40
+ const A = await readPoints(P, a.de), B = await readPoints(P, a.ate);
41
+ if (!A.length || !B.length) throw new Error("as planilhas precisam de pontos com posição");
42
+ const res = nearest(A.map((p) => [p.lat, p.lon]), B.map((p) => [p.lat, p.lon]));
43
+ const arquivo = okFile(a.arquivo, ".csv") ? a.arquivo : `${a.de.replace(/\.csv$/i, "")}-mais-proximo.csv`;
44
+ const cols = Object.keys(A[0].props).filter((c) => !c.startsWith("_"));
45
+ const rows = [[...cols, "latitude", "longitude", "mais_proximo", "distancia_m"],
46
+ ...A.map((p, i) => [...cols.map((c) => p.props[c] ?? ""), String(p.lat), String(p.lon), nameOf(B[res[i].j]?.props, res[i].j), String(Math.round(res[i].metros))])];
47
+ Project.writeSheet(P, arquivo, rows);
48
+ const media = res.reduce((s, r) => s + r.metros, 0) / res.length, far = res.reduce((m, r) => (r.metros > m.metros ? r : m), res[0]);
49
+ return { tipo: a.tipo, arquivo, linhas: A.length, oque: `distância de cada item de ${a.de} até o mais próximo de ${a.ate}: média ${fmtMetros(media)}, maior ${fmtMetros(far.metros)} (${nameOf(A[far.i].props, far.i)} até ${nameOf(B[far.j].props, far.j)})` };
50
+ }
51
+ if (a.tipo === "contar") {
52
+ const pts = await readPoints(P, a.pontos);
53
+ if (a.area) {
54
+ const areas = readShapes(P, a.area).filter((f) => /Polygon/.test(f.geometry?.type || ""));
55
+ const counts = areas.map((f, k) => ({ nome: nameOf(f.properties, k), n: pts.filter((p) => inPolygon([p.lat, p.lon], f.geometry)).length }));
56
+ return { tipo: a.tipo, arquivo: a.area, linhas: counts.length, oque: `pontos de ${a.pontos} dentro de cada área: ${counts.map((c) => `${c.nome}: ${c.n}`).join("; ")}` };
57
+ }
58
+ const centro = await placeOf(a.centro, cfg), metros = Number(a.metros) || 1000;
59
+ const dentro = withinRadius(pts.map((p) => [p.lat, p.lon]), centro, metros);
60
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `contexto/raio-${Math.round(metros)}m.geojson`;
61
+ Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `raio de ${fmtMetros(metros)}`, dentro: dentro.length }, geometry: circle(centro, metros) }]));
62
+ const nomes = dentro.slice(0, 8).map((i) => nameOf(pts[i].props, i)).join(", ");
63
+ return { tipo: a.tipo, arquivo, linhas: dentro.length, oque: `${dentro.length} de ${pts.length} pontos de ${a.pontos} ficam a até ${fmtMetros(metros)}${nomes ? ` (${nomes}${dentro.length > 8 ? "…" : ""})` : ""}; o círculo foi gravado` };
64
+ }
65
+ if (a.tipo === "faixa") {
66
+ const metros = Number(a.metros) || 50;
67
+ const lines = readShapes(P, a.linha).filter((f) => /LineString/.test(f.geometry?.type || ""));
68
+ if (!lines.length) throw new Error(`${a.linha} não tem linhas`);
69
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `${a.linha.replace(/\.[^.]+$/, "")}-faixa-${Math.round(metros)}m.geojson`;
70
+ Project.writeText(P, arquivo, fc(lines.map((f, k) => ({ type: "Feature", properties: { nome: `${nameOf(f.properties, k)} (faixa de ${fmtMetros(metros)})` }, geometry: lineBuffer(f.geometry, metros) }))));
71
+ let extra = "";
72
+ if (a.pontos) {
73
+ const pts = await readPoints(P, a.pontos);
74
+ const perto = pts.filter((p) => lines.some((f) => pointLine([p.lat, p.lon], f.geometry) <= metros));
75
+ extra = `; ${perto.length} de ${pts.length} pontos de ${a.pontos} ficam a até ${fmtMetros(metros)} da linha${perto.length ? ` (${perto.slice(0, 8).map((p, i) => nameOf(p.props, i)).join(", ")}${perto.length > 8 ? "…" : ""})` : ""}`;
76
+ }
77
+ return { tipo: a.tipo, arquivo, linhas: lines.length, oque: `faixa de ${fmtMetros(metros)} em volta de ${a.linha}${extra}` };
78
+ }
79
+ if (a.tipo === "sugerir") {
80
+ const pts = await readPoints(P, a.existentes);
81
+ const area = a.area ? readShapes(P, a.area).find((f) => /Polygon/.test(f.geometry?.type || ""))?.geometry : null;
82
+ const sug = suggestSites(pts.map((p) => [p.lat, p.lon]), { area, quantos: Math.min(Number(a.quantos) || 3, 10) });
83
+ const arquivo = okFile(a.arquivo, ".csv") ? a.arquivo : "contexto/sugestoes.csv";
84
+ Project.writeSheet(P, arquivo, [["nome", "latitude", "longitude", "distancia_m"], ...sug.map((s, k) => [`Sugestão ${k + 1}`, s.lat.toFixed(6), s.lon.toFixed(6), String(Math.round(s.metros ?? 0))])]);
85
+ return { tipo: a.tipo, arquivo, linhas: sug.length, oque: `lugares mais longe dos existentes em ${a.existentes}${a.area ? ` dentro de ${a.area}` : ""}: ${sug.map((s, k) => `Sugestão ${k + 1} a ${fmtMetros(s.metros)} do mais próximo`).join("; ")} (sugestão geométrica: confira acesso, terreno e regras antes)` };
86
+ }
87
+ if (a.tipo === "rota") {
88
+ onProgress("calculando a rota…");
89
+ const de = await placeOf(a.de, cfg), para = await placeOf(a.para, cfg);
90
+ const r = await route([de, para], { modo: a.modo, cfg });
91
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : "contexto/rota.geojson";
92
+ Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `${typeof a.de === "string" ? a.de : "origem"} até ${typeof a.para === "string" ? a.para : "destino"}`, distancia_m: Math.round(r.distancia), minutos: Math.round(r.duracao / 60), modo: MODOS[r.modo] }, geometry: { type: "LineString", coordinates: r.coords } }]));
93
+ return { tipo: a.tipo, arquivo, linhas: 1, oque: `rota ${MODOS[r.modo]}: ${fmtMetros(r.distancia)}, cerca de ${Math.round(r.duracao / 60)} min` };
94
+ }
95
+ if (a.tipo === "alcance") {
96
+ onProgress("calculando a área alcançável…");
97
+ const minutos = Math.min(Math.max(Number(a.minutos) || 10, 1), 60);
98
+ const origens = typeof a.de === "string" && /\.csv$/i.test(a.de)
99
+ ? (await readPoints(P, a.de)).slice(0, 8).map((p, i) => ({ at: [p.lat, p.lon], nome: nameOf(p.props, i) }))
100
+ : [{ at: await placeOf(a.de, cfg), nome: String(a.de) }];
101
+ const feats = [];
102
+ for (const o of origens) feats.push({ type: "Feature", properties: { nome: `${o.nome}: ${minutos} min ${MODOS[a.modo] || "a pé"}` }, geometry: await isochrone(o.at, minutos, { modo: a.modo, cfg }) });
103
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `contexto/alcance-${minutos}min.geojson`;
104
+ Project.writeText(P, arquivo, fc(feats));
105
+ return { tipo: a.tipo, arquivo, linhas: feats.length, oque: `área alcançável em ${minutos} min ${MODOS[a.modo] || "a pé"} a partir de ${origens.length} ${origens.length === 1 ? "ponto" : "pontos"}` };
106
+ }
107
+ if (a.tipo === "encaixar") {
108
+ onProgress("encaixando o rastro nas ruas…");
109
+ const f = readShapes(P, a.trilha).find((x) => /LineString/.test(x.geometry?.type || ""));
110
+ if (!f) throw new Error(`${a.trilha} não tem um rastro (linha)`);
111
+ const coords = f.geometry.type === "LineString" ? f.geometry.coordinates : f.geometry.coordinates.flat();
112
+ const step = Math.max(1, Math.ceil(coords.length / 100)); // o serviço aceita até 100 pontos: amostra o rastro
113
+ const out = await matchTrace(coords.filter((_, i) => i % step === 0).map(([lon, lat]) => [lat, lon]), { modo: a.modo, cfg });
114
+ const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `${a.trilha.replace(/\.[^.]+$/, "")}-nas-ruas.geojson`;
115
+ Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `${nameOf(f.properties, 0)} (nas ruas)` }, geometry: { type: "LineString", coordinates: out } }]));
116
+ return { tipo: a.tipo, arquivo, linhas: 1, oque: `rastro de ${a.trilha} encaixado nas ruas (${out.length} pontos)` };
117
+ }
118
+ throw new Error(`análise desconhecida: ${a.tipo}`);
119
+ }