汪群超 Chun-Chao Wang

Dept. of Statistics, National Taipei University, Taiwan

QT Designer + PyQt + 地理地圖

Objective:

將 Folium 地圖呈現在 PyQt 的 GUI。


範例 1: 在 PyQt GUI 中呈現 Folium 地圖,如下圖。

程式功能:利用經緯度呈現 Folium 地圖並加註該地點的指標(Mark)。

注意事項:

  1. 要使用 Folium 地圖,必須安裝套件,譬如 pip install folium

  2. 要呈現在 PyQt 的 widget 元件,則必須安裝 WebEngine,以 PyQt 6 為例:pip install PyQt6-WebEngine。若是 PyQt 5 則是 pip install PyQtWebEngine。

  3. 本範例用 comboBox 放入三個地點並事先從 Google map 查詢到經緯度。

  4. 預備呈現地圖的位置先放置一個 vertical Layout 的元件,把位置框出來。準備在程式裏面利用 addWidget 的方式加入地圖 widget。

  5. 下列程式碼的重點在 def show_map(self, coordinate),其中參數 coordinate 則是某個地點的經緯度,表達為 (緯度, 經度),譬如 (24.xxxx, 112.xxxx)

  6. 要正確的呈現 Folium 地圖在 QT 上,有幾個動作:

    • 根據經緯度建立 Folium 物件,譬如程式碼中的 m 變數。

    • 為一個或多個位置加入圖標 marker(圖中藍色標籤)。Folium 的圖標功能很豐富(形狀、塗顏色、拉直線 … 等),有很多選擇,可參考使用手冊或網路上其他的作品。

    • 先將地圖資料轉換為 Bytes 型態。

    • 再建立一個可以呈現 HTML 網頁的 widget,並將地圖轉成網頁型態。

    • 最後將該網頁型態的地圖資料(已經是一個 GUI 上的 widget),以 addWidget 的方式加入原先在 Designer 設計好的 Vertival Layout。為避免後續呈現其他地圖時,重複 addWidget,必須先清除舊的 widget。

  7. Folium 地圖本身具有互動式的功能,可以直接在上面 Zoom in/out 並且可以拖移。非常方便。

# Show folium map in pyqt6 + Designer

from PyQt6.QtWebEngineWidgets import QWebEngineView # pip install PyQt6-WebEngine
from PyQt6 import QtWidgets, uic
import folium # pip install folium
import sys
import io

"""
Folium in PyQt6
"""
class MainWindow(QtWidgets.QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        
        uic.loadUi('PyQt_Designer_GeoMap.ui', self)

        # coordinate = (37.8199286, -122.4782551) # 金門大橋
        coordinate1 = (24.944752335627687, 121.3708067871758) # 台北大學
        coordinate2= (25.058691930742544, 121.54250844112391)
        coordinate3 = (25.05664120926736, 121.53747661711536)
        self.loc_coordinate = {"台北大學三峽校區":coordinate1, 
                               "台北大學民生校區":coordinate2, 
                               "台北大學建國校區":coordinate3 }
        loc = self.comboBox_campus.currentText()
        self.show_map(self.loc_coordinate[loc])

        # signals
        self.comboBox_campus.currentIndexChanged.connect(self.which_campus)

    def show_map(self, coordinate):
        m = folium.Map(
        	tiles='Stamen Terrain',
        	zoom_start=13,
        	location=coordinate
        )
        # save map data to data object
        data = io.BytesIO()
        folium.Marker(location = coordinate).add_to(m) # 插入圖標
        m.save(data, close_file = False)

        webView = QWebEngineView()  # a QWidget
        webView.setHtml(data.getvalue().decode())

        # clear the current widget in the verticalLayout before adding one
        if self.verticalLayout.itemAt(0) : # if any existing widget
            self.verticalLayout.itemAt(0).widget().setParent(None)
        # add a widget with webview inside the vertivalLayout component
        self.verticalLayout.addWidget(webView, 0) # at position 0
    
    def which_campus(self):
        loc = self.comboBox_campus.currentText()
        self.show_map(self.loc_coordinate[loc])

def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()


練習: 製作一張地圖,呈現台北聯合大學的四所學校的圖標,如圖一;也可以呈現任一學校的位置,如圖二。


注意事項:

  1. 不論是呈現地圖的指令 folium.Map 或是圖標的指令 folium.Marker,都只能一次呈現一個位置。想呈現多個位置的圖標時,必須選擇其中的某一個位置來呈現地圖,譬如圖一選擇中間位置的校區(台北科技大學),再走迴圈將四個校區的位置皆標上圖標。同時給每個圖標 popup 名稱,如圖一點選圖標後挑出來的「台北大學」,方便使用者辨識圖標屬於誰。

  2. 圖一的縮圖比例採 zoom_start = 10,圖二採 zoom_start = 13,依目的性調整適當大小。

圖一、先呈現四個學校的位置圖標
圖二、選擇任一學校

練習: 試著表現地圖呈現的樣貌。下圖呈現的是五百多家乳牛畜牧場的位置,其中圖標以圓形表示,滑鼠滑過圖標時顯示牧場名稱,點選圖標除顯示牧場名稱外,再加入緯度與經度,來表達不同的內容。


注意事項:

  1. 圓形圖標的指令為 folium.CircleMarker,決定大小的參數為 radius=3, 決定顏色的參數是 color=’red’,圓形圖標內的顏色為 fill_color=’red’,鼠標滑過呈現內容:tooltip=”任何文字”,點擊圖標出現內容:popup=”任何文字”。

  2. 這裡呈現的五百多牧場的名稱與經緯度存放在一個 EXCEL 檔,經 pandas 讀入牧場資料。

圖一、滑鼠滑過圖標顯示該牧場的名稱
圖二、滑鼠點擊圖標顯示牧場名稱與經緯度。

練習: 地圖常需要呈現邊境線,將某個區域框起來,以凸顯地區性,譬如將台灣的 22 個行政區框起來,如下圖所示。

台灣行政區域邊界地圖檔:點取下載(解壓縮後為 JSON 檔)

注意事項:

  1. 要匡列出邊境線條必須採集邊界線的多點經緯度,並根據 GeoJSON(一種基於JSON的地理空間數據交換格式)格式預儲為檔案,方便輸出。本練習來自一個台灣 22 個行政區的邊界線經地圖檔,內含有邊界經緯度的 JSON 格式:{ “type”: “MultiPolygon”, “coordinates”: …….} 格式型態為「Multipolygon」多邊形,後面經緯度(coordinates)便是構成多邊形的點,數量非常龐大。實際做法如下列程式。

  2. 將檔案中每個縣市的 { “type”: “MultiPolygon”, “coordinates”: …….} 交給指令 GeoJson(),加上一些外觀的參數,便能劃出匡線,也可以在匡線內塗上顏色。

  3. 下列四張圖刻意呈現 folium.map 的不同表現,由參數 tiles 決定外觀。其中圖一是最清晰的地圖,經持續放大後可看見詳細的街道與名稱,而且很新。

  4. 下列程式碼中,將地圖資料呈現出來的方式與前個範例不同。這裡採用 widget 元件並將之提升到 QWebEngineView 的類別,程式中直接使用 self.webEngineView.setHtml(data.getvalue().decode()),其中 self.webEngineView 代表該元件的名稱。將元件提升的方式可以參考後面範例 5 的圖三。

圖一、台東縣邊境(不設定 tiles)
圖二、花蓮縣邊境(採 tiles = ‘Stamen Toner’)
圖三、台東邊境(採 tiles = ‘Stamen Watercolor’)
圖四、南投縣邊境(採 tiles = ‘Stamen Terrain’)
from PyQt6.QtWebEngineWidgets import QWebEngineView # pip install PyQt6-WebEngine
from PyQt6 import QtWidgets, uic
import folium # pip install folium
from folium import GeoJson
import json
import sys
import io

"""
Folium in PyQt6
"""
class MainWindow(QtWidgets.QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        
        uic.loadUi('PyQt_Designer_GeoMap_border.ui', self)
        f = open('../Data/geo_taiwan.json',encoding='utf8')
        self.data = json.load(f)
        self.get_city()
        self.show_map()

        #signals
        self.comboBox_city.currentIndexChanged.connect(self.show_map)

    def show_map(self):
        idx = self.comboBox_city.currentIndex()
        self.county = self.data['features'][idx]['geometry']
        m = folium.Map(
        	# tiles='Stamen Terrain', # tiles = Stamen Toner, CartoDB positron, Cartodb dark_matter, Stamen Watercolor or Stamen Terrain
        	zoom_start=8, # 適當的放大倍數能看到全島
            location = (23.73, 120.96) # 以台灣中部山脈為中心點
        ) 
        # m = folium.Map(width="%100",weight="%100") # 完整的世界地圖
        GeoJson(self.county,
            style_function=lambda feature: {
                'fillColor': '#adff2f',
                'color':'yellow'}).add_to(m)
        # save map data to data object
        data = io.BytesIO()
        m.save(data, close_file = False)
        self.webEngineView.setHtml(data.getvalue().decode())

    # 取得檔案中縣市的名稱
    def get_city(self):
        cities = []
        for i in range(len(self.data['features'])):
            cities.append(self.data['features'][i]['properties']['name'])
        self.comboBox_city.addItems(cities)

def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

範例 2: 統計面量圖(Choropleth map)
當統計量涉及與區域位置時,可以搭配地圖來呈現統計量,其視覺化效果往往能直接了當地表達統計量的意義。譬如,下圖以 Folium map 外加 Choropleth map 呈現 COVID-19 在全世界各國造成的確診數情況,圖中以六種顏色代表不同層度的確診數,塗在國家的國境範圍。

資料來源:Taiwan CDC Open data Portal / 每日更新
網址:https://data.cdc.gov.tw/zh_TW/dataset/covid-19countrystatsjson
Geojson 資料網址:https://od.cdc.gov.tw/eic/covid19/covid19map.json
注意事項:

  1. 由於資料可從網路直接下載,且每日更新,因此讀取資料最好的方式便是連接網路 (urlopen),做法可參考下列程式所示。

  2. 程式中將下載的 json 資料用 pandas 處理,方便作後續的統計數字的提取與組合。好好利用強大的 pandas,可以少寫幾行程式。

  3. 產生面量圖的指令 folium.choropleth 內有很多屬性供設定,最主要的除了地理資料(geo_data)與統計量資料(data)外,便是讓地理資料與統計資料對照的 columns 與 key_on。columns 內指定的名稱是統計資料內的欄位,第一個將與 key_on 所指定的地理資料內的名稱對照。因此必須仔細從 json 地理資料中找出對應的 key。

  4. 下圖的面量分六個顏色,呈現六個統計區間,區間範圍採內訂的平均間隔。但由於全球疫情的嚴重程度在各國間差異甚大,因此大部分區域均顯示相同的淡黃色(14,000,000 以下)。這樣的區分方式看起來不是最好的,讀者可以試著介入,如下列程式備註的部分:threshold_scale = custom_scale,也就是在 custom_scale 下點功夫,試試百分位的區分法或自己設定數字。

  5. 目前將 folium 製作的地圖放到 PyQT 的 GUI 上,必須先轉成 HTML 的網頁格式並塞到一個特定的 widget 裡,而當這個網頁的內容太大,超過 2M 時,地圖便秀不出來。通常會讓地圖過大的原因在 Geojson 的地理資料太大(譬如過於詳細的邊境資料)。下圖的地理資料是國與國的邊境,邊界經緯度給得比較稀疏,資料量反而小。

from PyQt6 import QtCore, QtWidgets, QtGui, uic
from PyQt6.QtWebEngineWidgets import QWebEngineView 
from urllib.request import urlopen
from datetime import datetime
import folium
import pandas as pd
import json
import sys
import os
import io

class MainWindow(QtWidgets.QMainWindow):
 
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
         
        uic.loadUi('PyQt_Designer_GeoMap_Covid19_world.ui', self)
        #------------------------------------------------------
        # collect data from URL
        JSON = 'https://od.cdc.gov.tw/eic/covid19/covid19map.json'
        with urlopen(JSON) as f:
            self.geo_covid19 = json.load(f)

        # open local data file
        # json_file_path = r'../Data/covid19map.json'
        # with open(json_file_path, 'r', encoding='utf-8') as j:
        #     self.geo_covid19 = json.loads(j.read())
        #------------------------------------------------------
        # prepare data and display some statistics on the labels
        self.df = pd.DataFrame(columns=['country', 'cases', 'deaths'])
        data = self.geo_covid19['features']
        for i in range(len(data)):
            self.df.loc[i] = [data[i]['properties']['name'], 
                            data[i]['properties']['cases'], 
                            data[i]['properties']['deaths']
                            ]
          
        cases_global = self.df['cases'].sum()
        cases_taiwan = self.df['cases'][self.df['country'].str.contains('Taiwan')]
        self.label_cases.setText("{:,}".format(cases_taiwan.iloc[0]) + '/' + "{:,}".format(cases_global))
        deaths_global = self.df['deaths'].sum()
        deaths_taiwan = self.df['deaths'][self.df['country'].str.contains('Taiwan')]
        self.label_deaths.setText("{:,}".format(deaths_taiwan.iloc[0]) + '/' + "{:,}".format(deaths_global))
        #------------------------------------------------------
        self.show_map()
        
    def show_map(self):
        #----- display current date and time -------------
        now = datetime.now()
        dt_string = now.strftime("%Y-%m-%d %H:%M:%S")
        legendTxt = 'Taiwan:' + dt_string
        self.label_date.setText(legendTxt)
        #-------------------------------------------------
        m = folium.Map(width="%100",weight="%100") # 完整的世界地圖
        # custom_scale = (self.df['cases'].quantile((0,0.2,0.4,0.6,0.8,1))).tolist()
        # custom_scale = [0, 1e6, 2*1e6, 3*1e6, 4*1e6, 1e7, 9e7]
        folium.Choropleth(
                geo_data = self.geo_covid19, # geojson data or file
                name = "choropleth",
                data = self.df, # 與地圖配合的統計量(pandas dataframe)
                columns = ["country","cases"], # 指定作圖的欄位
                key_on = 'feature.properties.name', # 指定 geojson 資料中將與 columns 對應的 key
                # threshold_scale = custom_scale, # 客制化的 legend scale
                fill_color = 'YlOrRd',
                nan_fill_color="White",
                fill_opacity = 0.7,
                line_opacity = 0.5,
                reset=True,
                legend_name = legendTxt).add_to(m)
        

        data = io.BytesIO()
        m.save(data, close_file = False)
        webView = QWebEngineView()  # a QWidget
        webView.setHtml(data.getvalue().decode()) # html size < 2M

        self.verticalLayout.addWidget(webView, 0) # at position 0
 
        
def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())
 
if __name__ == '__main__':
    main()


練習: 統計面量圖(Choropleth map)的延伸。
上個範例的 Geojson 地理資料含各國地理邊界外,最主要的兩個組資料是 COVID-19 的確診人數與死亡人數。本練習提供群組面量圖的技術供參考。可以透過選項視窗選擇不同資料的面量圖,甚至可以搭配表現方式,如下三張圖。

注意事項:

  1. 選擇群組式的面量圖時,原先的 legend 不見了,看不出顏色代表的數量。

  2. 下圖的顏色分配接採用 quantile 模式,表現出較前範例更具分別性。

圖一、確診人數面量圖
圖二、切換到死亡人數面量圖
圖三、選擇暗黑背景模式
from PyQt6 import QtCore, QtWidgets, QtGui, uic
from PyQt6.QtWebEngineWidgets import QWebEngineView 
from urllib.request import urlopen
from datetime import datetime
import folium
import pandas as pd
import json
import sys
import os
import io


class MainWindow(QtWidgets.QMainWindow):
 
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
         
        uic.loadUi('PyQt_Designer_GeoMap_Covid19_world.ui', self)
        #------------------------------------------------------
        # collect data from URL
        JSON = 'https://od.cdc.gov.tw/eic/covid19/covid19map.json'
        with urlopen(JSON) as f:
            self.geo_covid19 = json.load(f)

        # open local data file
        # json_file_path = r'../Data/covid19map.json'
        # with open(json_file_path, 'r', encoding='utf-8') as j:
        #     self.geo_covid19 = json.loads(j.read())
        #------------------------------------------------------
        # prepare data and display some statistics on the labels
        self.df = pd.DataFrame(columns=['country', 'cases', 'deaths'])
        data = self.geo_covid19['features']
        for i in range(len(data)):
            self.df.loc[i] = [data[i]['properties']['name'], 
                            data[i]['properties']['cases'], 
                            data[i]['properties']['deaths']
                            ]
          
        cases_global = self.df['cases'].sum()
        cases_taiwan = self.df['cases'][self.df['country'].str.contains('Taiwan')]
        self.label_cases.setText("{:,}".format(cases_taiwan.iloc[0]) + '/' + "{:,}".format(cases_global))
        deaths_global = self.df['deaths'].sum()
        deaths_taiwan = self.df['deaths'][self.df['country'].str.contains('Taiwan')]
        self.label_deaths.setText("{:,}".format(deaths_taiwan.iloc[0]) + '/' + "{:,}".format(deaths_global))
        #------------------------------------------------------
        self.show_map()
        
    def show_map(self):
        #----- display current date and time -------------
        now = datetime.now()
        dt_string = now.strftime("%Y-%m-%d %H:%M:%S")
        legendTxt = '確診人數:' + dt_string
        self.label_date.setText(legendTxt)
        #-------------------------------------------------
        m = folium.Map(width="%100",weight="%100",tiles=None, overlay=False) # 完整的世界地圖
        fg1 = folium.FeatureGroup(name='Cases', overlay=False).add_to(m)
        fg2 = folium.FeatureGroup(name='Deaths', overlay=False).add_to(m)

        custom_scale = (self.df['cases'].quantile((0,0.2,0.4,0.6,0.8,1))).tolist()
        # custom_scale = [0, 1e6, 2*1e6, 3*1e6, 4*1e6, 1e7, 9e7]
        folium.Choropleth(
                geo_data = self.geo_covid19,#Assign geo_data to your geojson file
                name = "Cases",
                data = self.df,#Assign dataset of interest
                columns = ["country","cases"],#Assign columns in the dataset for plotting
                key_on = 'feature.properties.name',#Assign the key that geojson uses to connect with dataset
                threshold_scale = custom_scale, #use the custom scale we created for legend
                fill_color = 'YlOrRd',
                nan_fill_color="White",
                fill_opacity = 0.7,
                line_opacity = 0.5,
                reset=True,
                legend_name = legendTxt).geojson.add_to(fg1)

        folium.Choropleth(
                geo_data = self.geo_covid19,#Assign geo_data to your geojson file
                name = "Deaths",
                data = self.df,#Assign dataset of interest
                columns = ["country","deaths"],#Assign columns in the dataset for plotting
                key_on = 'feature.properties.name',#Assign the key that geojson uses to connect with dataset
                threshold_scale = custom_scale, #use the custom scale we created for legend
                fill_color = 'YlOrRd',
                nan_fill_color="White",
                fill_opacity = 0.7,
                line_opacity = 0.5,
                reset=True,
                legend_name = legendTxt).geojson.add_to(fg2)

        #Add layer control to the map
        folium.TileLayer('cartodbdark_matter',overlay=True,name="View in Dark Mode").add_to(m)
        folium.TileLayer('cartodbpositron',overlay=True,name="Viw in Light Mode").add_to(m)
        folium.LayerControl(collapsed=False).add_to(m)

        data = io.BytesIO()
        m.save(data, close_file = False)
        webView = QWebEngineView()  # a QWidget
        webView.setHtml(data.getvalue().decode()) # html size < 2M

        self.verticalLayout.addWidget(webView, 0) # at position 0

def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())
 
if __name__ == '__main__':
    main()


範例 3: 統計面量圖(Choropleth map)與數據呈現(tooltip popup)
前一範例的統計面量圖可說是在原來的 Folium 地圖上疊上一層代表區域統計量的顏色,如圖一地圖上的顏色代表台灣各行政區的人口數;另一種表達地理資訊統計量的方式如圖二,依賴鼠標的位置呈現該區域的統計量,這也是再原來的 Folium 圖上疊了一層會跳出小視窗的 Tooltip。第三種常見的方式便是整合 Choropleth 與 Tooltip 的彈跳視窗的表現,如圖三。

不論是 Choropleth 或 tooltip 都是以區域疆界為基礎,因此需要 geojson 的地理資訊檔,譬如,台灣行政區域的經緯度。另外需要與地理資訊相關的統計資料,譬如,各行政區的人口數或 COVID-19 的確診人數等。一般而言需要兩種檔案的支援,一是 geojson 檔,另一個是可以用 pandas 套件來表達的統計資料檔。

為了方便處理 geojson 的地理資料與 pandas 統計資料表,最好安裝 geopandas 套件,可以減少一些程式的麻煩。不過 geopandas 的安裝有點費事(如果能 python -m pip install geopandas 便安裝成功,算幸運的。)一般的安裝情形都不順利,主要是這個套件牽涉到版本問題,而 pip 無法處理。目前需要以「手動」的方式安裝相關套件,雖然有點費事,總算可以成功安裝。安裝程序如 Geopandas Installation

資料檔下載: 台灣行政區地理資料與台灣人口數
注意事項:

  1. 下列程式碼呈現圖三。若要呈現圖一,則只需註解 folium.GeoJson() 這一段`; 若要呈現圖二,則註解 folium.Choropleth() 這一段。

  2. 相較於前面的範例,這個範例的特殊處在於利用 geopandas 將地理資訊與統計量結合成一個 pandas 文件,好利用 pandas 處理資料的能力。讀者可以試著從 debug 模式,觀察下列程式碼中變數 Dataset, geojson 與 df_final 的內容,才容易理解後面的 Choropleth 與 Tooltip 的原理。

圖一、Folium map + Choropleth layer
圖二、Folium map + Tooltip layer

圖三、Folium map + Choropleth + Tooltip
# Reference: https://towardsdatascience.com/folium-and-choropleth-map-from-zero-to-pro-6127f9e68564#:~:text=In%20Python%2C%20there%20are%20several%20graphing%20libraries%20that,country%20with%20a%20variety%20of%20flavors%20and%20designs.

from PyQt6 import QtCore, QtWidgets, QtGui, uic
from PyQt6.QtWebEngineWidgets import QWebEngineView 
import folium.folium
import geopandas as gpd
import pandas as pd
import json
import sys
import os
import io
from pathlib import Path

Dataset=pd.read_excel('../Data/Taiwan_population.xlsx') # 讀入台灣人口數
json_file_path = r'../Data/geo_taiwan_short.json'
# 讀入台灣行政區地理資料並取行政區名稱與界線經緯度
geojson = gpd.read_file(json_file_path, encoding='utf-8')
geojson=geojson[['name','geometry']]
# 整合人口數資料與行政區地理資料為單一 pandas 變數
df_final = geojson.merge(Dataset, left_on="name", right_on="City/County", how="outer") 
df_final = df_final[~df_final['geometry'].isna()]

with open(json_file_path, 'r', encoding='utf-8') as j:
     geo_taiwan = json.loads(j.read())
     

class MainWindow(QtWidgets.QMainWindow):
 
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
         
        uic.loadUi('PyQt_Designer_GeoMap_choropleth.ui', self)
        self.show_map()

        
    def show_map(self):
        current_dir = Path(__file__).resolve().parent
        m = folium.Map(location=[23.73, 120.96], zoom_start=7)
        folium.Choropleth(
                geo_data = geo_taiwan,#Assign geo_data to your geojson file
                name = "choropleth",
                data = df_final,#Assign dataset of interest
                columns = ["City/County","Population"],#Assign columns in the dataset for plotting
                key_on = 'feature.properties.name',#Assign the key that geojson uses to connect with dataset
                fill_color = 'YlOrRd',
                fill_opacity = 0.7,
                line_opacity = 0.5,
                reset=True,
                legend_name = '台灣縣市人口').add_to(m)
        
        folium.GeoJson(
                data=df_final, #Dataset merged from pandas and geojson
                name='Populations',
                smooth_factor=2,
                style_function=lambda x: {'color':'black','fillColor':'transparent','weight':0.1},
                tooltip=folium.GeoJsonTooltip(
                       fields=['City/County',
                                'Population'
                               ],
                       aliases=['行政區',
                                '人口數'
                                ], 
                        localize=True,
                        sticky=False,
                        labels=True,
                        style="""
                            background-color: #F0EFEF;
                            border: 2px solid black;
                            border-radius: 3px;
                            box-shadow: 3px;
                        """,
                        max_width=800),
                highlight_function=lambda x: {'weight':3,'fillColor':'grey'},
                        ).add_to(m) 

        data = io.BytesIO()
        m.save(data, close_file = False)
        filename = os.fspath(current_dir / "map.html")
        m.save(filename)
        webView = QWebEngineView()  # a QWidget
        webView.setHtml(data.getvalue().decode()) # html size < 2M

        self.verticalLayout.addWidget(webView, 0) # at position 0
        
def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())
 
if __name__ == '__main__':
    main()


範例 4: 上述範例以網頁的型態呈現地圖,代表 PyQt 有能力呈現一般網頁內容。藉著上個範例的程式碼,練習做一個「類 Browser」,如下圖。

程式功能:透過輸入 url 網址,呈現網頁內容。

注意事項:

  1. 不論是呈現地圖或是網頁,都是 QWebEngineView 的功能,差別之處詳見下列程式碼。

  2. 下列程式呈現網頁的區塊仍先在 Designer 設計階段,先用 vertical Layout 框出網頁範圍,之後在程式裡面以 addWidget 的方式將 QWebEngineView 所製作的 widget 加進來。因為採 addWidget 的方式,因此每次換新網址時,都要先 clear 原來的 widget,再 addWidget 一次。這個方法看起來似乎有點囉嗦,甚至多此一舉,何不直接在先建立一個空白的 widget,之後只要丟網頁進去即可?讀者可以嘗試看看。

  3. 在 GUI 可以忠實地呈現網頁內容,也能從網頁內的超連結點出新的網頁內容。不過,畢竟不是真正的瀏覽器,功能仍受限。

# Show web-content in pyqt6 + Designer

from PyQt6.QtWebEngineWidgets import QWebEngineView # pip install PyQt6-WebEngine
from PyQt6 import QtWidgets, uic
from PyQt6.QtCore import QUrl
import sys

class MainWindow(QtWidgets.QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        
        uic.loadUi('PyQt_Designer_Browser.ui', self)
        self.urlBrowser()

        # signals
        self.lineEdit_url.returnPressed.connect(self.urlBrowser)

    def urlBrowser(self):
        # url = 'https://new.ntpu.edu.tw/'
        url = "https://" + self.lineEdit_url.text()
        webView = QWebEngineView()
        webView.load(QUrl(url))
        
        # clear the current widget in the verticalLayout before adding one
        if self.verticalLayout.itemAt(0) : # if any existing widget
            self.verticalLayout.itemAt(0).widget().setParent(None)
        
        self.verticalLayout.addWidget(webView)

def main():
    app = QtWidgets.QApplication(sys.argv)
    main = MainWindow()
    main.show()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

範例 5: 上述範例的網頁以 addwidget 的方式加入一個設計好的 vertivallayout 裡面。本範例採用與 pyqtgraph 繪圖區相同的方式,將一個 widget 元件提升到 QWebEngineView,可以直接呈現網頁。另外,也加入 WebEngineView 的上一頁(back)、下一頁(forward)與重新載入(reload) 等功能,讓 GUI 更像 browser,如圖一與二。


注意事項:

  1. 在 Designer 設計網頁瀏覽元件時,採 widget 元件,按此元件右鍵選擇提升到 QWebEngineView,如圖三所示。呈現的方式如下的程式碼,只需要一行指令:self.webEngineView.load(QUrl(url))

  2. GUI 所需的「上一頁、下一頁與重新載入」的小圖示,來自網站 https://www.flaticon.com/ 提供的免費 icon。使用 icon 貼在 label 元件的方式,可以先下載,之後在設計階段直接以 pixmap 貼上。下列程式碼則是在執行階段才連線下載貼上,比較費事,不過適合程式碼的移動,不需要老揹著幾張圖。

  3. 下列程式碼費了較多篇幅處理「上一頁、下一頁與重新載入」的功能,包括啟動三個 label 的 signal 及上一頁與下一頁何時該出現、該隱藏等,都必須逐一處理。對使用著看起來沒甚麼「感覺」的操作行為,在 GUI 的程式設計卻是一點也不能疏漏。

  4. 本範例參考 109 級學生黃雁莞的作品。

    圖一、左上角有一個「重新載入」的小圖
    圖二、經過幾個網頁瀏覽後,便出現上一頁與下一頁的小圖
    圖三、在 Designer 中瀏覽元件的提升
    # Show web-content in pyqt6 + Designer
    
    from PyQt6.QtWebEngineWidgets import QWebEngineView # pip install PyQt6-WebEngine
    from PyQt6 import QtWidgets, uic, QtGui, QtCore
    from PyQt6.QtCore import QUrl
    import urllib.request
    import sys
    
    
    class MainWindow(QtWidgets.QMainWindow):
    
        def __init__(self, *args, **kwargs):
            super(MainWindow, self).__init__(*args, **kwargs)
            
            uic.loadUi('PyQt_Designer_Browser_WebEngineView.ui', self)
            self.getIcon('https://cdn-icons-png.flaticon.com/512/93/93641.png', 'label_reload')
            self.getIcon('https://cdn-icons-png.flaticon.com/512/570/570220.png',  'label_back')
            self.getIcon('https://cdn-icons-png.flaticon.com/512/570/570221.png', 'label_forward')
            self.label_back.setHidden(True)
            self.label_forward.setHidden(True)
            self.urlBrowser()
    
            # signals
            self.lineEdit_url.returnPressed.connect(self.urlBrowser)
            self.webEngineView.page().urlChanged.connect(self.urlChanged)
            self.webEngineView.page().urlChanged.connect(self.onLoadFinished)
            self.label_back.installEventFilter(self)
            self.label_forward.installEventFilter(self)
            self.label_reload.installEventFilter(self)
    
        def getIcon(self, imglink, label):
            data = urllib.request.urlopen(imglink).read()
            image = QtGui.QImage()
            image.loadFromData(data)
            tmp = f'self.{label}.setPixmap(QtGui.QPixmap(image))'
            exec(tmp)
    
        # Slots
        def urlBrowser(self):
            # url = "https://" + self.lineEdit_url.text()
            url = self.lineEdit_url.text()
            self.webEngineView.load(QUrl(url))
            
    
        def eventFilter(self, obj, event):
            if obj is self.label_back and event.type() == QtCore.QEvent.Type.MouseButtonRelease:
                self.back()
            if obj is self.label_forward and event.type() == QtCore.QEvent.Type.MouseButtonRelease:
                self.forward()
            if obj is self.label_reload and event.type() == QtCore.QEvent.Type.MouseButtonRelease:
                self.reload()
            return super().eventFilter(obj, event)
        
        def back(self):
            self.webEngineView.back()
    
        def forward(self):
            self.webEngineView.forward()
        
        def reload(self):
            self.webEngineView.reload()
        
        def urlChanged(self, url):
            self.lineEdit_url.setText(url.toString())
        
        def onLoadFinished(self):
            if self.webEngineView.history().canGoBack():
                self.label_back.setHidden(False)
            else:
                self.label_back.setHidden(True)
    
            if self.webEngineView.history().canGoForward():
                self.label_forward.setHidden(False)
            else:
                self.label_forward.setHidden(True)
    
    
    def main():
        app = QtWidgets.QApplication(sys.argv)
        main = MainWindow()
        main.show()
        sys.exit(app.exec())
    
    if __name__ == '__main__':
        main()
    
商學院  7F16
ccw@gm.ntpu.edu.tw
(02)8674-1111 
ext 66777

部落格統計

  • 122,303 點擊次數
%d bloggers like this: