Lesson 2 of 2
Em andamento

Criação de Mapas Interativos e Painéis de Controlo de dados georreferenciados

Ricardo 16 de Setembro, 2024

Com este tutorial, poderás aprender como:

  1. Desenvolver aplicações de web interativas: Criar uma web app interativa utilizando o framework dash em Python.
  2. Visualizar dados geoespaciais: Importar e manipular dados geoespaciais usando as bibliotecas pandas e geopandas. Além disso, aprenderá a plotar dados geoespaciais em mapas interativos utilizando as funcionalidades do plotly.
  3. Manipular dados: Realizar operações de manipulação de dados, como filtragem, agregação e cálculos de novas colunas, utilizando as funcionalidades do pandas.
  4. Criar gráficos interativos: Criar gráficos interativos utilizando o plotlye o plotly.express. Criar gráficos de dispersão, gráficos de linha, mapas de calor e outros tipos de visualizações interativas.
  5. Integrar de elementos de interface: Adicionar elementos de interface como botões, sliders e tabelas interativas ao aplicativo dash. Esses elementos irão permitir a interação com os dados e ajustar as visualizações conforme as necessidades.
  6. Aplicar na prática a análise de alertas de derramamentos de óleo: O tutorial concentra-se na análise de dados de alertas de derramamentos de óleo, permitindo visualizar a localização dos derramamentos, explorar os dados por meio de filtros de tempo e área, e visualizar informações detalhadas sobre os alertas selecionados.

Esta aplicação é a original, no entanto, os elementos usados não estarão completamente disponíveis, sendo um desafio tentar reproduzir esta visualização com recurso aos dois materiais disponibilizados.

1 – Importar os pacotes necessários

Neste capítulo, estamos a importar as bibliotecas e módulos necessários para a aplicação. As bibliotecas importadas são as seguintes:

  • numpy (abreviação: np): Biblioteca para suporte a arrays multidimensionais e funções matemáticas de alto desempenho.
  • dash: Biblioteca para criação de aplicações web interativas baseadas em Python.
  • html e dcc (componentes do Dash): Módulos para criação de elementos HTML e componentes interativos em Dash.
  • plotly.graph_objs (abreviação: go): Módulo contendo objetos gráficos do Plotly para criação de visualizações interativas.
  • pandas (abreviação: pd): Biblioteca para manipulação e análise de dados em formato de tabela.
  • geopandas (abreviação: gpd): Biblioteca para manipulação e análise de dados geoespaciais em formato de tabela.
  • plotly.express (abreviação: px): Módulo que simplifica a criação de visualizações interativas usando o Plotly.
# Importação dos pacotes necessários
import numpy as np
import dash
from dash import html, dcc, Input, Output, dash_table
import plotly.graph_objs as go
import pandas as pd
import geopandas as gpd
import plotly.express as px

2 – Carregar e pré-processar os dados

Os dados são carregados a partir dos arquivos “pickledata_oil.pickle e stations.pickle.

Um “pickle” é um formato em Python usado para converter objetos Python numa representação binária e vice-versa. Permite salvar objetos num arquivo ou enviá-los por uma rede e, em seguida, recuperá-los posteriormente, mantendo sua estrutura e estado original.

# Carregar os dados do pickle
data = pd.read_pickle(r'oil_app\data_oil.pickle')

# Calcular 'width' baseado na 'area' e 'length'
data['width'] = 4 * data.area / (np.pi * data.length)

# Criar a coluna 'registry_nr' com valores de 1 até ao número de entradas
data['registry_nr'] = [i for i in range(1, len(data) + 1)]

# Converter a coluna 'date' para datetime format do pandas
data['date'] = pd.to_datetime(data.date)

# Extrair o 'year' from the 'date' column
data['year'] = [date.year for date in data.date]

# Carregar as estações e portos onde poderão possivelmente serem postos drones para a verificação (estes contem a autonomia prevista)
stations = pd.read_pickle(r'oil_app\stations.pickle')

# define color
stations['colors'] = ['red' if vehicle=='Aéreo' else 'blue' for vehicle in stations['Veículo não Tripulado']]

# Cria um dicionário com os números e nomes dos meses
meses_nomes = {1: 'Jan', 2: 'Fev', 3: 'Mar', 4: 'Abr', 5: 'Mai', 6: 'Jun', 
               7: 'Jul', 8: 'Ago', 9: 'Set', 10: 'Out', 11: 'Nov', 12: 'Dez'}

# Obtém as datas mínima e máxima do conjunto de dados
data_minima = min(data['date'])
data_maxima = max(data['date'])

# Cria uma lista de datas para serem usadas como marcações no slider
datas = pd.date_range(start=data_minima, end=data_maxima, freq='MS').tolist()

# Cria uma lista de números de mês correspondentes às datas
numeros_meses = [data.month for data in datas]

# Cria uma lista de nomes de mês correspondentes aos números de mês
nomes_meses = [meses_nomes[numero] for numero in numeros_meses]

# Cria um dicionário de marcações para o slider
marcas = {numero: nome for numero, nome in zip(numeros_meses, nomes_meses)}

# Obtém a lista de anos e meses únicos no conjunto de dados
anos_unicos = data['date'].dt.year.unique()

Neste código, os dados são carregados a partir dos arquivos “pickle” usando as funções pd.read_pickle(). De seguida, são realizadas as etapas de pré-processamento conforme descrito.

Para o DataFrame data, é feito o cálculo da coluna ‘width’ com base nas colunas ‘area’ e ‘length’, assumindo que as manchas são elipsoidais. A coluna ‘registry_nr’ é criada com valores sequenciais de 1 até o comprimento dos dados, pois é permitido disponibilizar o dia do mês nem a hora do registo. A coluna ‘date’ é convertida para o formato de data e hora usando pd.to_datetime(). A partir da coluna ‘date’, é extraído o ano e as colunas ‘year’.

No DataFrame stations, a coluna ‘colors’ foi criada com base nos valores da coluna ‘Veículo não Tripulado’, onde ‘Aéreo’ recebe a cor ‘red’ e os demais valores recebem a cor ‘blue’, para os destinguir a posteriori.

Posteriormente, criamos uma variedade de listas úteis para a definição da aplicação.

Estas etapas de carregamento e pré-processamento de dados preparam os dados para serem usados posteriormente na construção do aplicativo Dash.

Nota: Podes descarregar aqui (oil_spill_alerts) a versão oficial dos alertas e aqui (stations) das estações onde possivelmente se poderiam localizar drones para a verificação dos derramamentos de óleo, em csv. Podes alterar o código de acordo com o que realmente será necessário.

 

Para o que vamos fazer será necessário criar uma função que desenha os círculos

from draw_circle_on_map import draw_circle_on_map

Aqui podes ver o conteúdo do ficheiro draw_circle_on_map.py

import numpy as np
import pandas as pd
import plotly.express as px

def draw_circle_on_map(lat, lon, radius_km, color):
# Convert radius from km to degrees of longitude using Haversine formula
R_earth = 6371 # Earth's radius in km
dlon = radius_km / (R_earth * np.cos(np.radians(lat)))
dlat = radius_km / R_earth

# Construct a circle polygon
bearings = np.arange(0, 361)
circle_lon = lon + np.degrees(dlon) * np.cos(np.radians(bearings))
circle_lat = lat + np.degrees(dlat) * np.sin(np.radians(bearings))
circle_df = pd.DataFrame({'lat': circle_lat, 'lon': circle_lon})

# Create a scatter mapbox plot with the circle as a filled polygon
fig = px.scatter_mapbox(circle_df, lat='lat', lon='lon',
mapbox_style='carto-positron', zoom=8,
center={'lat': lat, 'lon': lon},
hover_data={'lat': True, 'lon': True},
color_discrete_sequence=[color])
fig.update_traces(mode='lines+markers', marker=dict(size=3), line = dict(width=3))

return fig

 

3 – Definição do layout da aplicação Dash

Para começar, vamos desconstruir uma criação simples de uma aplicação onde se apenas se visualizam os locais dos alertas e a sua legenda, desta forma:

Para tal podemos proceder da seguinte forma:

# Cria a aplicação Dash
app = dash.Dash(__name__)

# Define o layout da aplicação
app.layout = html.Div(children=[
    # Título da página
    html.H1('Mapa de Cluster de Alertas de Derramamento de Óleo', style={'text-align': 'center'}),
    
    # Mapa para exibir as localizações dos alertas
    dcc.Graph(id='oil_map', style={'height': '600px', 'width': '1000px'}) #nome do mapa é oil_map
])

# Define o callback da aplicação para atualizar o mapa
@app.callback(
    Output('oil_map', 'figure'), #queremos o oil_map
    Input('oil_map', 'id') #no local do oil_map definido anteriormente
)
def update_map(_):
    # Cria um gráfico de dispersão no mapa com marcadores de cluster
    fig = px.scatter_mapbox(data, lat='lat', lon='lon', zoom=4, height=600)
    fig.update_layout(mapbox_style="open-street-map")
    fig.update_layout(margin={"r":0,"t":0,"l":0,"b":0})
    
    return fig

if __name__ == '__main__':
    # Executa o aplicativo Dash em modo de debugging
    app.run_server(debug=True)

 

Esse código cria uma aplicação de Dash simples que exibe um mapa interativo com marcadores de cluster representando os alertas. O mapa é atualizado automaticamente sempre que houver alguma alteração no elemento com o ID ‘oil_map’. Ao executar o código, a aplicação inicia-se num servidor local e poderá ser acedido no navegador para visualizar o mapa interativo.

Habitualmente é numa localização como esta:

Na secção de código fornecida, o layout da aplicação Dash.

  • app.layout é um objeto html.Div que contém todos os elementos visuais do aplicativo.
  • html.H1 cria um elemento de cabeçalho de nível 1 (H1) com o texto “Mapa de Cluster de Derramamentos de Óleo”. O estilo definido com style={'text-align': 'center'} centraliza o texto.
  • dcc.Graph cria um elemento de gráfico interativo com o ID ‘oil_map’. Este gráfico será usado para exibir as localizações dos derramamentos de óleo. O estilo definido com style={'height': '600px', 'width': '1000px'} define a altura e largura do gráfico.

 

4 – Definição dos callbacks

A definição das funções de callback que atualizam os componentes do aplicativo com base nas interações do usuário.

Nessa secção do código, define-se o callback responsável por atualizar o mapa quando necessário.

  • @app.callback é usado para definir um callback. Especifica a saída esperada e as entradas que ativam o callback.
  • Output('oil_map', 'figure') especifica que o retorno do callback será atribuído à figura do gráfico com o ID ‘oil_map’.
  • Input('oil_map', 'id') especifica a entrada que aciona o callback. Neste caso, é o ID do gráfico ‘oil_map’, usado como um espaço reservado para a entrada, mas não é usado no corpo do callback.

Dentro do callback, temos a lógica que atualiza o gráfico:

  • fig = px.scatter_mapbox(data, lat='lat', lon='lon', zoom=4, height=600) cria um gráfico de dispersão no mapa usando os dados fornecidos (data). As colunas ‘lat’ e ‘lon’ são usadas para as coordenadas das localizações no mapa. O zoom e a altura do gráfico também são definidos.
  • fig.update_layout(mapbox_style="open-street-map") define o estilo do mapa como “open-street-map“.
  • fig.update_layout(margin={"r":0,"t":0,"l":0,"b":0}) define as margens do layout do gráfico para remover o espaço em branco ao redor do mapa.

O retorno do callback é a figura atualizada (fig), que será exibida no gráfico ‘oil_map’.

O restante do código diz respeito à inicialização do aplicativo Dash e a sua execução em modo de debugging.

 

5 – Aumentar a complexidade da aplicação

O que foi apresentado antes é uma versão ‘simpática’ da aplicação, no entanto, de interativa não tem nada. Para tal, faz uma leitura cuidada dos passos seguintes de uma aplicação muito mais avançada que permite a exploração dos dados.

# Cria a aplicação
app = dash.Dash(__name__, external_stylesheets=['style.css'])

# Define o layout do aplicativo
app.layout = html.Div(children=[
    # Título da página
    html.H1('Derramamentos de Óleo Potenciais', style={'text-align': 'center'}),

    # Abas
    dcc.Tabs(id='tabs', value='map_tab', children=[
        # Aba para o mapa interativo
        dcc.Tab(label='Mapa', value='map_tab', children=[
            # Botão para selecionar área ou comprimento
            html.H4('Escolha entre círculos representando a Área do derramamento de
                             óleo ou representando a proporção entre a Área real e a Área assumindo que o derramamento de óleo é uma elipse'),
            dcc.RadioItems(
                id='area_ratio',
                options=[{'label': 'Área', 'value': 'area'},
                         {'label': 'Proporção', 'value': 'ratio'}],
                value='area',
                labelStyle={'display': 'inline-block', 'margin-right': '20px'}
            ),

            # Slider para selecionar faixa de mês e ano
            html.H3('Escolha faixas de mês e ano'),
            html.Div([
                dcc.RangeSlider(
                    id='year_slider',
                    min=data['date'].dt.year.min(),
                    max=data['date'].dt.year.max(),
                    value=[data['date'].dt.year.min(), data['date'].dt.year.max()],
                    marks={str(year): str(year) for year in data['date'].dt.year.unique()},
                    step=None,
                    vertical=True,
                    className='black-slider'
                ),
                dcc.RangeSlider(
                    id='month_slider',
                    min=min(month_numbers),
                    max=max(month_numbers),
                    value=[min(month_numbers), max(month_numbers)],
                    marks=marks,
                    step=None,
                    vertical=True,
                    className='black-slider',
                ),
                # Mapa para exibir locais de derramamento de óleo
                html.Div([html.H3('Localização dos alertas de derramamento de óleo'),
                          html.H4('Escolha entre mapa estático e mapa animado por ano'),
                          dcc.RadioItems(id='toggle-animation',
                                         options=[
                                             {'label': 'Nenhum', 'value': 'None'},
                                             {'label': 'Ano', 'value': 'year'}],
                                         value='None',
                                         labelStyle={'display': 'inline-block', 'margin-right': '10px'}
                                         ),
                          html.P('Neste gráfico, você pode escolher um conjunto de pontos para ver a tabela de dados abaixo!'),
                          dcc.Graph(id='oil_map', style={'height': '600px', 'width': '1200px'}),
                          ], style={"marginTop": "20px", 'backgroundColor': '#90EE90'}),
            ], style={'display': 'flex'}),
        ]),

        # Aba para tabela
        dcc.Tab(label='Tabela de dados selecionados', value='stations_table', children=[
            # Tabela para exibir os dados selecionados
            dash_table.DataTable(
                id='selected_data_table',
                columns=[{"name": i, "id": i} for i in data.drop(columns=['date']).columns],
                page_size=10
            )
        ]),

        # Aba para tabela de estações
        dcc.Tab(label='Estações', children=[
            # Tabela de estações
            html.Div([
                # Mapa para exibir locais de possíveis estações
                html.Div([html.H3('Localização de possíveis estações'),
                          html.P('Na tabela abaixo, você pode escolher as estações de UAV e USV a serem representadas no mapa'),
                          dcc.Graph(id='stations_map', style={'height': '500px', 'width': '1000px'})]),
                dash_table.DataTable(
                    id='stations_table',
                    columns=[{"name": i, "id": i} for i in stations.columns],
                    data=stations.to_dict('records'),
                    row_selectable='multi',
                    style_table={'height': '200px', 'overflowY': 'auto', 'whiteSpace': 'normal',
                                 'width': '600px'},
                    selected_rows=[1, 4, 7, 9, 11, 16]
                ),
            ]),
        ]),
    ])
])

# Define o callback da aplicação
@app.callback(
    Output('oil_map', 'figure'),
    Input('area_ratio', 'value'),
    Input('year_slider', 'value'),
    Input('month_slider', 'value'),
    Input('toggle-animation', 'value')
)
def update_map(area_ratio, year_range, month_range, toggle):
    o_df = data[['area', 'lat', 'lon', 'length', 'date', 'ratio', 'year']]
    if toggle == 'None':
        toggle = None
    # Filtra os dados com base na seleção do ano e mês
    df = data[['area', 'lat', 'lon', 'length', 'date', 'ratio', 'year']]
    df = df[(df['date'].dt.year >= year_range[0]) & (df['date'].dt.year <= year_range[1])] df = df[(df['date'].dt.month >= month_range[0]) & (df['date'].dt.month <= month_range[1])] # Cria um gráfico de dispersão no mapa if max(data[area_ratio]) > 300:
        max_range = 200
        tickvals_ar_ra = [0, 50, 100, 150, 200]
        ticktext_ar_ra = ['0', '50', '100', '150', '>200']
    else:
        max_range = 1
        tickvals_ar_ra = [0, 0.2, 0.4, 0.6, 0.8, 1]
        ticktext_ar_ra = ['0', '0.2', '0.4', '0.6', '0.8', '>1']

    df['marker_size'] = df[area_ratio]

    o_df['marker_size'] = o_df[area_ratio]

    min_marker_size = o_df['marker_size'].min()

    max_marker_size = o_df['marker_size'].max()

    fig = px.scatter_mapbox(df,
                            lat='lat',
                            lon='lon',
                            hover_data=['lat', 'lon', 'area', 'ratio', 'length'],
                            size='marker_size',
                            color=area_ratio,
                            color_continuous_scale='jet',
                            animation_frame=toggle,
                            range_color=[0, max_range],
                            zoom=4.1,
                            center={'lat': 37.5, 'lon': -18.0})
    fig.update_layout(coloraxis_colorbar=dict(
        title=area_ratio,
        tickvals=tickvals_ar_ra,
        ticktext=ticktext_ar_ra,
    ))

    if toggle == 'year':
        fig.layout.updatemenus[0].buttons[0].args[1]['frame']['duration'] = 1000
        fig.layout.updatemenus[0].buttons[0].args[1]['transition']['duration'] = 250
    fig.update_layout(mapbox_style="open-street-map")
    fig.update_layout(
        plot_bgcolor='#90EE90',
        paper_bgcolor='#90EE90'
    )
    return fig


@app.callback(
    Output('selected_data_table', 'data'),
    Input('month_slider', 'value'),
    Input('year_slider', 'value'),
    Input('oil_map', 'selectedData')
)
def update_table(month_range, year_range, selectedData):
    if selectedData:
        selected_indices = [point['pointIndex'] for point in selectedData['points']]
        selected_df = data.iloc[selected_indices].copy()
    else:
        selected_df = data.copy()
    selected_data = selected_df[
        (selected_df['date'].dt.month >= month_range[0]) & (selected_df['date'].dt.month <= month_range[1]) & (selected_df['date'].dt.year >= year_range[0]) & (selected_df['date'].dt.year <= year_range[1])]
    selected_data = selected_data.to_dict('records')
    return selected_data


@app.callback(
    Output('stations_map', 'figure'),
    Input('stations_table', 'data'),
    Input('stations_table', 'selected_rows')
)
def update_stations_map(data_s, selected_rows):
    # Cria um dataframe com as linhas selecionadas
    selected_data = pd.DataFrame(data_s).iloc[selected_rows, :]

    # Cria um mapa vazio
    fig = px.scatter_mapbox(selected_data, lat='lat', lon='lon', zoom=3.5, height=600,
                            color='Veículo não Tripulado',
                            color_discrete_sequence=['red', 'blue'])
    fig.update_layout(mapbox_style="open-street-map")

    fig2 = px.scatter_mapbox(data,
                             lat='lat',
                             lon='lon',
                             hover_data=['lat', 'lon', 'area', 'ratio', 'length'],
                             zoom=4.1,
                             center={'lat': 37.5, 'lon': -18.0})

    for i, row in selected_data.iterrows():
        circle_fig = draw_circle_on_map(row['lat'], row['lon'], row['usv_uav_autonomy_km'], row['colors'])
        fig.add_trace(circle_fig.data[0])

    fig.update_layout(mapbox_style="open-street-map")
    fig.add_trace(fig2.data[0])
    fig.update_layout(coloraxis_showscale=False)
    return fig

if __name__ == '__main__':
    app.run_server(debug=True)

 

O código apresentado é resumidamente contem:

  • Definição do layout do aplicativo, incluindo 3 guias (tabs), gráficos em duas das guias, tabelas em duas das guias e elementos que permitem a interação.
  • Definição dos callbacks do aplicativo, que são funções que são acionadas quando ocorrem interações do utilizador, como alterar filtros ou selecionar pontos no mapa.
  • Callbacks responsáveis por atualizar os gráficos e tabelas com base nas interações do usuário.
  • A execução do aplicativo num servidor local para visualização e interação.

Obtendo-se uma app como aceder num repositório gratuito como o render https://potential-oil-spills-in-portugal.onrender.com/ que oferece a possibilidade de publicar uma página de Dash.

Esta aplicação é meramente ilustrativa de todas as possibilidades ao nosso alcance para se obter uma aplicação que auxilie um investigador na exploração dos dados e para começar por delinear estratégias de ação para apurar padrões, para planeamento da ação a tomar para a localização dos meios de verificação bem como a previsão do local ‘futuro’ da mancha para otimizar o tempo de ação para a sua verificação.

Existem várias opções de serviços cloud onde se pode implantar Dash apps. Alguns mais populares são:

  1. Microsoft Azure: O Azure oferece uma variedade de serviços, como Azure App Service, Azure Virtual Machines e Azure Kubernetes Service (AKS), que podem ser usados para estas apps.
  2. Google Cloud Platform (GCP): O GCP oferece serviços como Compute Engine e App Engine que podem ser usados para hospedar estas apps. Além disso, o Google Kubernetes Engine (GKE) permite aplicações Dash em containers usando o Kubernetes.
  3. Heroku: Heroku é uma plataforma cloud popular para aplicativos da web.
  4. PythonAnywhere: PythonAnywhere é uma cloud especializada em hospedar aplicações Python. Oferecem suporte ao Dash e permitem implementar e executar aplicações Dash facilmente.

Esses são apenas alguns exemplos de provedores populares, mas existem muitos outros disponíveis. Cada provedor tem as suas próprias vantagens, preços e recursos, portanto, é recomendável pesquisar e comparar as opções para encontrar aquela que melhor satisfaça as necessidades e orçamento.

 

5 – Utilidade da visualização interativa.

A visualização interativa desempenha um papel fundamental na análise de dados e na comunicação de informações complexas.

Exploração de dados: A visualização interativa permite explorar os dados em detalhe, permitindo a manipulação e filtragem dos dados de acordo com o que se pretende. Isso possibilita a descoberta de insights ocultos, identificação de padrões e tendências e a compreensão mais profunda dos dados.

Comunicação eficaz: Torna a comunicação da informação complexa mais fácil e compreensível. Um utilizador pode interagir com os gráficos e visualizações, ajustar parâmetros, explorar diferentes perspectivas e obter respostas imediatas às suas perguntas, facilitando a transmissão de conhecimento.

Análise de cenários: Com visualizações interativas, podemos analisar diferentes cenários ajustando variáveis, modificando parâmetros e observando as mudanças contribuindo na análise de sensibilidade e na compreensão dos impactos potenciais das decisões tomadas.

Envolvimento do utilizador: A visualização interativa envolve os utilizadores ativamente, tornando a experiência mais envolvente e estimulante. Isso aumenta o interesse e a compreensão dos dados, incentivando a exploração e descoberta.

Tomada de decisões orientada por dados: Esta visualização fornece uma base sólida para a tomada de decisões orientada por dados. Podemos explorar diferentes perspectivas, comparar opções e avaliar os potenciais impactos antes de tomar decisões informadas e embasadas nos dados.