Computer/UxUi

Nicegui map 인터페이스

Kyubotics 2024. 4. 2. 15:50
app.add_static_files('/static', 'static', follow_symlink = True)
css = """
<style>
.number-icon {
    /* please replace by your own marker image, eg, /static/marker-icon-40x22.png! */
    background-image: url("http://peter.kleynjan.nl/css/marker-icon-40x22.png");
    background-size: 22px 40px;
    text-align: center;
    color: white;   
    font-size: 10pt;
    font-weight: 400;
    line-height: 18pt;
})
</style>"""

nicegui를 사용하면 파이썬에서 매우 쉽게 web 인터페이스를 구현할 수 있다.

자세한 사용 방법은 https://nicegui.io/ 페이지를 검색해보면 된다.

 

간혹 ROS 패키지를 작성하다가 UI가 필요한 경우에 많이 사용하게 되는데

때로는 지도에 어떤 마커라든지, 경로점 등을 표시하고 싶을 때가 있다. 이때 leaflet이라는 자바스크립트 플러그인을 활용하면 쉽게 구현할 수 있다. 

그런데 경우에 따라서는 기본 제공하는 마커 외에도 어떤 방향을 가진 마커 라던지 하는 특수한 마커를 표시하고 싶은 경우가 있을 것이다.

이 글에서는 nicegui의 leaflet 플러그인 기능을 확장해서 지도에 마커를 추가하고 활용하는 방법을 알아볼 것이다.

 

아래 그림은 지도에 표시된 마커에 회전된 sgv 아이콘이 적용된 화면이다.

 

leaflet이란?

leaflet은 오픈소스 기반의 자바스크립트 라이브러리로 널리 사용되는 지도 환경이다. 자세한 내용은 아래 링크를 참조한다.

https://leafletjs.com/

 

Leaflet — an open-source JavaScript library for interactive maps

Leaflet is a modern, lightweight open-source JavaScript library for mobile-friendly interactive maps.

leafletjs.com

nicegui에서 지도를 추가하는 방법은 다음 문서를 참조하면 된다.

https://nicegui.io/documentation/leaflet

 

ui.leaflet | NiceGUI

 

nicegui.io

leaflet에 마커 추가하기

간단하게 server.py라는 파일을 하나 만들고 다음과 같이 작성한 다음

# For nicegui
from nicegui import app, Client, ui, events

class WebServer:
    def __init__(self):
        with Client.auto_index_client:
            #create a row
            with ui.row().classes('w-full h-60 no-wrap').style("height: 100%"):
                self.ui_map = ui.leaflet(center=(37.500, 127.0363))
        ui.run()
            
if __name__ in { '__main__', '__mp_main__' } :
	WebServer()

 

python3 server.py 로 실행하면 지도가 하나 만들어진다.

 

이제 지도를 클릭했을 때 어떤 함수를 호출하도록 하는 루틴을 작성해보자

self.ui_map 을 선언한 곳 아래에 다음 내용을 추가하고

self.ui_map.on('map-click', self.handle_map_click)

이 코드가 호출할 함수를 다음과 같이 작성한다.

def handle_map_click(self, e: events.GenericEventArguments):
        lat = e.args['latlng']['lat']
        lng = e.args['latlng']['lng']
        print(f"lat:{lat}, long: {lng}")

 

그러면 지도를 클릭할때마다 터미널에 해당 위치의 위도, 경도를 출력하게 된다.

해당 위치에 마커를 추가하려면 handle_map_click 함수에 `self.ui_map.marker(latlng=(lat,lng))` 코드를 추가한다.

    def handle_map_click(self, e: events.GenericEventArguments):
        lat = e.args['latlng']['lat']
        lng = e.args['latlng']['lng']
        print(f"lat:{lat}, long: {lng}")
        self.ui_map.marker(latlng=(lat,lng))

이제 지도를 클릭하면 새로운 마커가 만들어진다.

 

그런데 이 마커는 아무런 표식도, 의미도 없으므로 어딘가에 해당 객체에 대한 정보를 가지고 있어야 한다.

그래서 __init__ 영역에서 리스트를 하나 선언하고 이 마커를 추가해주면 해당 마커에 대한 정보를 가지고 색인할 수 있다.

그러면 버튼을 하나씩 삭제할 수 도 있다.

    def __init__(self):
        with Client.auto_index_client:
            #create a row
            with ui.row().classes('w-full h-60 no-wrap').style("height: 100%"):
                self.ui_map = ui.leaflet(center=(37.500, 127.0363))
                self.ui_map.on('map-click', self.handle_map_click)
                ui.button('Remove', on_click=lambda :self.remove_marker())
        self.markers=[]
        ui.run()
        
    def handle_map_click(self, e: events.GenericEventArguments):
        lat = e.args['latlng']['lat']
        lng = e.args['latlng']['lng']
        print(f"lat:{lat}, long: {lng}")
        self.markers.append(self.ui_map.marker(latlng=(lat,lng))) 
        
    def remove_marker(self):
        if self.markers:
            self.ui_map.remove_layer(self.markers.pop(-1))

이제 마커를 추가했다가 remove버튼을 누르면 하나씩 마커가 제거되는 것을 확인할 수 있다.

 

인덱스가 있는 마커 추가하기

leaflet의 기본 마커는 아무런 표시가 없기때문에 지도에 어떤 순서로 클릭했는지 알 수 없다. 그럼 커스텀 된 마커를 추가할 수 있는 방법은 없을까?

leaflet은 기본적으로 자바스크립트로 구현된 플러그인이기 때문에 자바스크립트로 작성된 어떤것이든 추가할 수 있다.

그래서 마커를 클릭할때마다 인덱스를 추가하는 방법을 알아보자.

이 방법은 nicegui포럼에 다음 글을 참조하였다.

https://github.com/zauberzeug/nicegui/discussions/2361

 

Select marker · zauberzeug nicegui · Discussion #2361

Question Thank you for making NiceGui, realy nice! I am trying a leaflet app having markers. That works fine. But the markers represent objects with data properties. So I want to select a marker an...

github.com

글의 내용을 간단히 요약하면 다음과 같다.

먼저 server.py 가 있는 위치에서 static 이라는 폴더를 하나 만든 다음

그 폴더에 marker.js라는 파일을 만들고 다음 내용을 추가하자.

// markers.js test

var markers = { 'initialized': false, 'feature_group': null, 'icon': null };

function init_markers(map_id, icon_type) {
    // run after leaflet is loaded
    if ( ! markers.initialized ) {

        var map = getElement(map_id).map;       // see templates/index.html
        // map = window.app.$refs["r" + map_id].map;
        if ( ! map ) {
            console.log('Leaflet map object ' + map_id + ' not found');
            return;
        }  

        // group all markers in feature_group and add to map
        markers.feature_group = new L.featureGroup([]);
        map.addLayer(markers.feature_group);

        // define icon prototype, if necessary modify between adding markers
        markers.icon = L.DivIcon.extend({
            options: {
                className: "number-icon",
                iconSize: [22, 40],
                iconAnchor: [11,40],    // from top-left; add half the width. all of the height
                popupAnchor: [3, -40],
                html: ""
            }
        });

        markers.initialized = true;
    }
}

function add_marker(map_id, lat, lng, i, z=0) {
    init_markers(map_id);

    if ( ! markers.initialized ) {
        console.log('Markers not initialized / Leaflet probably not yet loaded');
        return;
    }

    var iconx = new markers.icon({ html: i });
    var marker = L.marker([lat, lng], {
        icon: iconx,
        zIndexOffset: z
    })
    .addTo( markers.feature_group )
    .on('click', handle_marker_click)

    // custom attribute, avoids closure in event handling & selector for deletion
    marker._ix = i;
}

function delete_marker(i) {
    if ( markers.initialized ) {
        markers.feature_group.eachLayer(function (layer) {
            if (layer._ix == i) {
                markers.feature_group.removeLayer(layer);
            }
        }
    )};
}

function delete_all_markers() { 
    if ( markers.initialized ) {
        markers.feature_group.remove();
        markers.feature_group = new L.featureGroup([]);
    }
}

// example event handlers
function handle_marker_click(e) { 
    // send ix back to Python
    emitEvent('marker-clicked', {'index': e.target._ix});
    // or do something locally
    delete_marker(e.target._ix);
};

또 static 폴더에 다음 마커 아이콘 파일을 다운로드 하고 파일이름을 marker-icon-40x22.png 라고 변경해준다.

 

그리고 이 파일을 server.py에 static 파일로 추가하기 위해 server.py 상단에 다음 내용을 한줄 추가해준다.

app.add_static_files("/static", "static")

이렇게 하면 어플에서 사용할 자바스크립트, 이미지 파일등을 브라우저의 static 경로로 로드하게 된다.

server.py 상단에 다음 내용도 추가해준다.

css = """
<style>
.number-icon {
    /* please replace by your own marker image, eg, /static/marker-icon-40x22.png! */
    background-image: url("/static/marker-icon-40x22.png");
    background-size: 22px 40px;
    text-align: center;
    color: white;   
    font-size: 10pt;
    font-weight: 400;
    line-height: 18pt;
})
</style>"""

class WebServer:
    def __init__(self):
        ui.add_head_html(css)
        ui.add_head_html("<script src='/static/markers.js'></script>")
        self.markers_id = 0

handle_map_click 함수를 다음과 같이 수정해주면 self.markers_id를 계속 추가해주면서 add_marker라는 함수를 호출하게 된다.

    def handle_map_click(self, e: events.GenericEventArguments):
        lat = e.args['latlng']['lat']
        lng = e.args['latlng']['lng']
        print(f"lat:{lat}, long: {lng}")
        ui.run_javascript(f"add_marker( {self.ui_map.id}, {lat}, {lng}, {self.markers_id})")
        self.markers_id+=1

이제 프로그램을 저장하고 다시 실행하면 다음과 같이 인덱스가 있는 마커가 표시되게 된다.

마커를 하나씩 제거하는것도 가능하다.

    def remove_marker(self):
        if self.markers_id > 0:
             self.markers_id -= 1
        ui.run_javascript(f"delete_marker( {self.markers_id})")

 

회전 가능한 마커 추가하기

마커는 어떤 위치만을 나타낼 수 있을 뿐 방향을 표시하지는 않는다.

비행기나 자동차의 위치를 표현할때는 방향도 같이 나오는 것이 필요하다. 

그럼 어떻게 해야 하는가?

 

위에 marker.js를 잘 살펴보면 답이 있다. 그리고 약간의 javascript 지식이 요구된다.

다행히 chatgpt는 이런 문제를 잘 알려준다.

몇번 시행착오 끝에 svg 라는 이미지 포맷을 활용하면 쉽게 구현할 수 있다.

역시 static 폴더 안에 이번에는 arrow.js 라는 파일을 하나 만들고 다음 코드를 추가한다.

// arrows.js test

var arrows = { 'initialized': false, 'feature_group': null, 'icon': null };

function init_arrows(map_id) {
    // run after leaflet is loaded
    if ( ! arrows.initialized ) {

        var map = getElement(map_id).map;       // see templates/index.html
        // map = window.app.$refs["r" + map_id].map;
        if ( ! map ) {
            console.log('Leaflet map object ' + map_id + ' not found');
            return;
        }  
        // Add markers to the map
        arrows.feature_group = L.layerGroup().addTo(map);

        arrows.initialized = true;
    }
}

function add_arrow(map_id, lat, lng, rotationAngle = 0, i=0, z=0) {
    init_arrows(map_id);
    if ( ! arrows.initialized ) {
        console.log('Markers not initialized / Leaflet probably not yet loaded');
        return;
    }
    // Create SVG icon as arrow and apply rotation
    var icon = L.divIcon({
        className: 'marker-icon',
        html:  `<svg width="50" height="60">\
                <!-- Main Icon -->
                <path d="M25 10 L10 50 L25 40 Z" fill="red"/>\
                <path d="M25 10 L25 40 L40 50 Z" fill="darkred"/>\
                </svg>`,
        iconAnchor: [25, 30],
        iconSize: [30, 40],
        iconRotation: rotationAngle
    });

    // Create marker
    var arrow = L.marker([lat, lng], {
        icon: icon,
        rotationAngle: rotationAngle
    }).addTo( arrows.feature_group )
    // Rotate using CSS
    var rotationStyle = 'transform: rotate(' + rotationAngle + 'deg);';
    arrow._icon.children[0].setAttribute('style', rotationStyle);
    arrow._ix = i;
    return arrow;
}

function delete_arrow(i) {
    if ( arrows.initialized ) {
        arrows.feature_group.eachLayer(function (layer) {
            if (layer._ix == i) {
                arrows.feature_group.removeLayer(layer);
            }
        }
    )};
}

function delete_all_arrows() { 
    if ( arrows.initialized ) {
        arrows.feature_group.remove();
        arrows.feature_group = new L.featureGroup([]);
    }
}

이번에는 icon 대신에 html에 svg 태그를 사용하여 임의의 아이콘을 만들고 

rotationStyle이라는 css 태그를 적용해서 아이콘을 회전하였다.

이 arrow.js 라는 코드를 static 폴더에 추가하기 위해 init에 다음 항목을 추가한다.

ui.add_head_html("<script src='/static/arrow.js'></script>")

이제 지도를 클릭했을 때 run_javascript 에서 add_marker 대신에 add_arrow 라는 함수를 호출하면 된다.

이때 회전하는 각도를 임의로 45로 넣으면 된다. 

ui.run_javascript(f"add_arrow( {self.ui_map.id}, {lat}, {lng}, {45}, {self.markers_id})")

이제 실행해보면 다음과 같이 지도에 45도 기울어진 화살표가 표시되는 것을 확인할 수 있다.

html 영역에 코드 확장하기

html의 기능을 알면 글자를 추가한다거나 하는 기능을 넣을 수 있다.

예를 들면 arrow.js의 html 영역에 다음과 같은 내용을 추가하면 

html:  `<svg width="50" height="60">\
        <!-- Main Icon -->
        <path d="M25 10 L10 50 L25 40 Z" fill="red"/>\
        <path d="M25 10 L25 40 L40 50 Z" fill="darkred"/>\
        <text x="25" y="35" text-anchor="middle" fill="white" font-size="12">\
        ${rotationAngle}</text>\
        </svg>`,

이렇게 각도를 표시할 수 있다.

 

그런데 글자 회전되어 있어서 보기가 어려우므로 글자에만 css의 transform 에다가 -값을 넎어서 되돌리는 코드를 넣어보자.

여기서 rotate 다음에 나오는 2개의 숫자는 회전하는 중심 x,y 좌표를 의미한다.

html:  `<svg width="50" height="60">\
        <!-- Main Icon -->
        <path d="M25 10 L10 50 L25 40 Z" fill="red"/>\
        <path d="M25 10 L25 40 L40 50 Z" fill="darkred"/>\
        <text x="25" y="35" text-anchor="middle" fill="white" font-size="12"\ 
        transform="rotate(${-rotationAngle} 25 30)">\
        ${rotationAngle}</text>\
        </svg>`,

이제 글자가 정상적으로 표시된다.

 

이 기능을 확장해보면 여러가지 다양한 기능을 추가해볼 수 있다.

예를 들면 그림자를 넣어본다던지...

var angle_rad = rotationAngle * Math.PI / 180.0
var dx = Math.sin(angle_rad) * 8
var dy = Math.cos(angle_rad) * 8
var icon = L.divIcon({
    className: 'marker-icon',
    html:  `<svg width="50" height="60">\
            <!-- Drop Shadow Filter -->
            <defs>
                <filter id="drop-shadow" x="-30%" y="-30%" width="200%" height="250%">
                    <feOffset in="SourceAlpha" dx="${dx}" dy="${dy}" result="offsetBlur"/>
                    <feGaussianBlur in="offsetBlur" stdDeviation="2" result="blur"/>
                    <feBlend in="SourceGraphic" in2="blur" mode="normal"/>
                </filter>
            </defs>
            <!-- Apply Drop Shadow Filter to the Icon -->
            <path d="M25 10 L10 50 L25 40 L40 50 Z" fill="black" opacity="0.4" filter="url(#drop-shadow)"/>
            <!-- Main Icon -->
            <path d="M25 10 L10 50 L25 40 Z" fill="red"/>\
            <path d="M25 10 L25 40 L40 50 Z" fill="darkred"/>\
            <!-- Text Area -->
            <rect x="12" y="25" width="28" height="12" fill="black" opacity="0.4" transform="rotate(${-rotationAngle} 25 30)"/>\
            <text x="25" y="35" text-anchor="middle" fill="white" font-size="12"\
            transform="rotate(${-rotationAngle} 25 30)">${Math.ceil(rotationAngle)}</text>\
            </svg>`,