[OpenLayers] AnimatedCluster 확장 기능

ol은 그 자체의 기능을 확장시키기 위한 확장 기능이 존재합니다. 그 중 하나가 바로 ol-ext이고, 이 곳에서 제공하는 AnimatedCluster 기능에 대해 살펴 보겠습니다.

먼저 ol-ext 기능을 ES6의 모듈방식으로 개발하기 위해 다음과 같은 ol-ext 모듈 설치가 필요합니다.

npm install ol-ext

이제 ol에도 이미 Cluster 기능이 존재하는데, 이 Cluster에 시각적인 효과를 추가한 것이 AnimatedCluster 입니다. 이에 대한 예제를 만들어 만들어 갈건데요. 먼저 완성된 예제의 실행 결과를 동영상으로 살펴보면 아래와 같습니다.

위의 결과는 지도 상에 10000개의 포인트 피쳐를 추가하여 인접한 포인트 간의 클러스터링을 적용하여 시각화하고 있습니다. 이제 예제 코드를 하나씩 살펴보겠습니다.

먼저 index.html 파일입니다.



    
        
        OpenLayers
        
    
                    
        

지도를 위한 div 하나와 index.js에 대한 스크립트 파일입니다. index.js 파일의 코드를 하나씩 살펴보면.. 먼저 필요한 모듈을 추가합니다.

import 'ol/ol.css';

import Feature from 'ol/Feature.js';
import Map from 'ol/Map.js';
import View from 'ol/View.js';
import Point from 'ol/geom/Point.js';
import VectorSource from 'ol/source/Vector.js';
import {Fill, Stroke, Style, Circle, Text} from 'ol/style.js';
import {Tile} from 'ol/layer.js';
import {XYZ, Cluster} from 'ol/source.js';
import AnimatedCluster from 'ol-ext/layer/AnimatedCluster.js'
import SelectCluster from 'ol-ext/interaction/SelectCluster.js'

먼저 10000개의 포인트 피쳐를 가지는 VectorSource 객체를 생성합니다. 이 벡터소스가 클러스터링 대상이 되는 데이터의 제공자입니다.

var featureCount = 10000;
var features = new Array(featureCount);
var feature, geometry;
 
for (var i = 0; i < featureCount; ++i) {
    geometry = new Point([14078579 + Math.random()*100000, 4487570 +  Math.random()*50000]);
    feature = new Feature(geometry);
    feature.set('id', i);
    features[i] = feature;
}

var vectorSource = new VectorSource({
    features: features
});

위의 벡터소스를 통해 클러스터링 알고리즘이 적용되어 또 다른 데이터소스가 만들어지게 됩니다. 아래의 코드는 클러스터링 알고리즘이 적용되어 만들어지는 데이터소스 객체와 이 데이터소스를 기반으로 하는 레이어로써 AnimatedCluster 객체를 생성합니다.

var clusterSource = new Cluster({
    distance: 200,
    source: vectorSource
});

var clusterLayer = new AnimatedCluster({	
    name: 'Cluster',
    source: clusterSource,
    animationDuration: 400,
    style: getStyle
});

clusterSource 객체를 생성하기 위한 옵션으로 distance는 화면의 px 단위 좌표로써 해당 px 값 범위 안의 포인트 요소를 하나의 그룹으로 묶는 기준값입니다. clusterLayer 객체 생성을 위한 옵션으로 animationDuration은 지도를 확대, 축소시 포인트 요소가 클러스터링 되는 것을 애니메이션으로 시각화해주는 시간을 ms 단위로 지정하며 style은 클래스터링 된 포인트 피쳐를 어떤 모양으로 지도 상에 표시할 것인지를 결정하는 함수입니다. getStyle이라는 함수가 지정되어 있는데, 해당 함수는 다음과 같습니다.

var styleCache = {};

function getStyle (feature, resolution) {	
    var size = feature.get('features').length;
    var style = styleCache[size];
    if (!style)
    {	
        var color = size>25 ? '192,0,0' : size>8 ? '255,128,0' : '0,128,0';
        var radius = Math.max(8, Math.min(size*0.75, 20));
        var dash = 2*Math.PI*radius/6;
        var dash = [ 0, dash, dash, dash, dash, dash, dash ];

        style = styleCache[size] = new Style({	
            image: new Circle({	
                radius: radius,
                stroke: new Stroke({
                    color: 'rgba('+color+',0.5)', 
                    width: 15,
                    lineDash: dash,
                    lineCap: 'butt'
                }),
                fill: new Fill({
                    color:'rgba('+color+',1)'
                })
            }),
            text: new Text({
                text: size.toString(),
                fill: new Fill({
                    color: '#fff'
                }),
                font: '10px Arial',
            })
        });
    }

    return [style];
}

이제 지도 객체를 생성합니다. 지도 객체는 다수의 레이어로 구성되어 있으며 앞서 생성한 클러스터 레이어와 함께 배경지도로써 VWorld의 TMS 배경지도 레이어로 구성합니다.

var base = new Tile({
    source: new XYZ({
        url: 'http://xdworld.vworld.kr:8080/2d/Base/service/{z}/{x}/{y}.png',
    })
});

var map = new Map({
    layers: [base, clusterLayer],
    target: document.getElementById('map'),
    view: new View({
        center:  [14128579.82, 4512570.74],
        zoom: 15
    })
});

여기까지만 실행해도 시각적인 클러스터링은 모두 완성된 것입니다. 여기에 더해 클러스터링 된 포인트 피처를 마우스나 터치로 선택할 경우 해당 피쳐의 ID값이나 선택된 그룹(부모) 피쳐가 몇개의 자식 피쳐로 구성되는지를 파악하는 코드를 추가해 보겠습니다.

부모 피쳐를 마우스나 터치로 선택하면 구성되는 자식 포인트 피쳐를 원형으로 펼쳐 표시하는데, 이때 사용되는 스타일 객체를 다음처럼 생성합니다.

// 자식 포인트의 스타일
var img = new Circle({	
    radius: 5,
    stroke: new Stroke({
        color: 'rgba(0,255,255,1)', 
        width: 1 
    }),
    fill: new Fill({
        color: 'rgba(0,255,255,0.3)'
    })
});

// 자식 포인트와 부모 피쳐 사이에 그릴 선에 대한 스타일
var linkStyle = new Style({
    image: img,
    stroke: new Stroke({
        color: '#fff', 
        width: 1 
    }) 
});

확장 기능으로써 AnimatedCluster에 대한 선택 기능 역시 확장 기능으로써 SelectCluster라는 인터랙션(Interaction)으로 수행됩니다. 아래의 코드가 이 인터랙션 객체를 생성합니다.

var selectCluster = new SelectCluster({	
    // 부모를 클릭하여 자식이 표시될때 부모와 자식간의 거리(px 단위)
    pointRadius:7,
    animate: true,
    // 부모와 자식 사이에 그려질 선에 대한 스타일
    featureStyle: function() {
        return [ linkStyle ];
    },
    // 부모가 선택된 상태에서 다시 부모와 자식이 선택될때 선택된 요소의 스타일
    style: function(f, res) {
        var cluster = f.get('features');
        if (cluster.length>1) {	 // 부모 스타일
            return getStyle(f,res);
        } else { // 자식 스타일
            return [
                new Style({	
                    image: new Circle({	
                        stroke: new Stroke({ color: 'rgba(0,0,192,0.5)', width:2 }),
                        fill: new Fill({ color: 'rgba(0,0,192,0.3)' }),
                        radius: 5
                    })
                })
            ];
        }
    }
});

부모나 자식이 선택될 경우에 이벤트를 받아 처리하여 원하는 기능을 원할하게 추가해야 합니다. 아래의 코드처럼요.

selectCluster.getFeatures().on(['add'], function (e)
{
    var c = e.element.get('features');
    if (c.length==1) { // 자식을 선택하때, 자식의 id 속성을 표시	
        var feature = c[0];
        console.log('Selected Feature Id = ' + feature.get('id'));
    } else { // 부모를 선택할때, 부모가 가진 자식의 개수를 표시
        console.log('Count = ' + c.length); 
    }
});

selectCluster.getFeatures().on(['remove'], function (e)
{	
    //.
});

마지막으로 생성한 인터렉션을 받을 지도에 추가합니다.

map.addInteraction(selectCluster);

[OpenLayers] Canvas를 활용한 ImageLayer

ol에서 제공하는 레이어중 개발자가 직접 Canavs를 생성하고, 이 Canvas에 도형을 그려 넣을 수 있는 기능을 가진 ImageLayer가 있습니다. 이 레이어는 지도 좌표를 가진 도형을 원하는 스타일로 자유롭게 다른 레이어와 어색하지 않게 표현해 줍니다. 이 글은 ImageLayer를 이용해 지도 좌표를 갖는 간단한 사각형을 표현하는 예제를 소개합니다.

먼저 index.html은 다음과 같습니다.



    
        
        OpenLayers
        
    
                    
        

그리고 위에서 언급된 index.js 파일인데, 하나씩 살펴보면.. 먼저 필요한 모듈에 대한 코드입니다.

import 'ol/ol.css';

import Map from 'ol/Map.js';
import View from 'ol/View.js';
import {Image as ImageLayer, Tile as TileLayer} from 'ol/layer.js';
import {ImageCanvas as ImageCanvasSource, Stamen} from 'ol/source.js';

그리고 지도 객체와 배경으로 사용할 레이어를 생성합니다.

var map = new Map({
    layers: [
        new TileLayer({
            source: new Stamen({
                layer: 'watercolor'
            })
        })
    ],
    target: 'map',
    view: new View({
        center: [50000, -50000],
        zoom: 9
    })
});

다음은 ImageLayer 객체를 생성하고 이 객체를 지도 객체에 추가하는 코드입니다.

var layer = new ImageLayer({
        source: new ImageCanvasSource({
        canvasFunction: canvasFunction,
        projection: 'EPSG:3857'
    })
});

map.addLayer(layer);

위의 코드 중 canvasFunction이 보이는데, 이 함수는 ImageLayer에서 Canvas 요소에 어떤 도형을 표현할 것인지에 대한 정의가 있습니다. 예를들어 다음과 같습니다.

function canvasFunction(extent, resolution, pixelRatio, size, projection) {
    var canvasWidth = size[0];
    var canvasHeight = size[1];

    var canvas = document.createElement('canvas');
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;

    var ctx = canvas.getContext('2d');

    var canvasOrigin = map.getPixelFromCoordinate([extent[0], extent[3]]);
    var mapExtent = map.getView().calculateExtent(map.getSize())
    var mapOrigin = map.getPixelFromCoordinate([mapExtent[0], mapExtent[3]]);
    var delta = [mapOrigin[0] - canvasOrigin[0], mapOrigin[1] - canvasOrigin[1]]

    var a1 = map.getPixelFromCoordinate([0, 0]); // [0, 0] -> EPSG:3857
    var a2 = map.getPixelFromCoordinate([100000, 100000]); // [100000, 100000] -> EPSG:3857
    
    ctx.fillStyle = "rgba(0,0,255,0.4)";
    ctx.fillRect(
        a1[0] + delta[0], a1[1] + delta[1], 
        Math.abs(a2[0]-a1[0]), Math.abs(a1[1]-a2[1])
    );

    return canvas;
};        

인자를 5개를 갖는 함수로 extent는 현재 지도의 extent, resolution은 현재 지도의 DPI, PixelRatio는 픽셀 비율값, 지도에 대한 DOM 요소의 크기, projection은 좌표계 정보인데 앞서 ImageLayer 생성시 projection을 ‘EPSG:3857’로 지정했으므로 이에 대한 제원정보를 갖는 투영객체가 할당됩니다. 코드를 보면 Canvas를 생성하기 위한 크기를 계산하여 지정하는 코드가 1-7번까지의 코드이고 11-14번은 도형의 좌표를 화면 좌표로 변환했을 시에 Canvas에 적용해야할 Offset값을 계산합니다. 이 값은 14번 코드의 delta에 저장됩니다. 16-17은 지도 좌표 (0,0)과 (100000, 100000) 좌표를 화면 좌표로 계산하여 a1, a2에 저장합니다. 이렇게 저장된 a1, a2를 이용해 Canvas에 사각형으로 그리는 코드가 19-23번 코드입니다. 최종적으로 처음 생성한 Canvas 객체를 반환해 줍니다. 실행해 보면 아래처럼 지도에 좌상단 좌표가 (0,0)이고 너비와 높이가 100000미터인 사각형이 그려진 것을 볼 수 있습니다.

[OpenLayers] 지도 상에 10만개의 별 그리기에 대한 속도 테스트

OpenLayers의 지도 속도를 테스트 하기 위해 10만개의 별을 배경지도 상에 그려보는 코드를 작성해 보았습니다.

먼저 실행에 대한 동영상은 아래와 같습니다. 실행은 크롬에서 했고, 2016년 6월 경에 구입한 DELL 노트북인 XPS 9550을 사용했습니다.

웹임에도 상당이 빠릅니다. 이유는 소스코드를 살펴보면 파악할 수 있습니다. 먼저 index.html 입니다.



    
        
        OpenLayers
        
    
                    
        

index.js에 대한 코드의 설명은 하나씩 언급해 보면, 먼저 필요한 모듈을 선언합니다.

import 'ol/ol.css';
 
import Feature from 'ol/Feature.js';
import Map from 'ol/WebGLMap.js';
import View from 'ol/View.js';
import Point from 'ol/geom/Point.js';
import VectorLayer from 'ol/layer/Vector.js';
import VectorSource from 'ol/source/Vector.js';
import {Fill, RegularShape, Stroke, Style} from 'ol/style.js';
import {Tile} from 'ol/layer.js';
import {XYZ} from 'ol/source.js';

그리고 10만개의 포인트를 표시할 스타일로 별 모양으로 지정하는 코드입니다. 이미지가 아닌 실행중에 별형상을 생성해 스타일로 사용하는 것입니다.

var style = new Style({
    image: new RegularShape({
        points: 5,
        scale: 1,
        radius: 10,
        radius2: 4,
        fill: new Fill({
            color: 'rgba(255, 255, 0, 1)'
        }),
        stroke: new Stroke({
            color: 'rgba(0, 0, 0, 1)',
            width: 2
        })
    })
});

다음은 10만개의 포인트 피쳐를 생성합니다. 좌표는 여의도를 덮을 정도의 영역으로 잡았습니다.

var featureCount = 100000;
var features = new Array(featureCount);
var feature, geometry;

for (var i = 0; i < featureCount; ++i) {
    geometry = new Point([14078579 + Math.random()*100000, 4487570 +  Math.random()*50000]);
    feature = new Feature(geometry);
    feature.setStyle(style);
    features[i] = feature;
}

위의 피쳐 데이터를 담기 위한 데이터소스와 이 데이터소스를 표현하기 위한 레이어 객체를 생성합니다.

var vectorSource = new VectorSource({
    features: features
});

var vector = new VectorLayer({
    source: vectorSource
});

배경지도도 표현해줘야 의미있는 테스트가 될듯하여 VWorld의 배경지도를 살짝 가져다 썻습니다.

var base = new Tile({
    source: new XYZ({
      url: 'http://xdworld.vworld.kr:8080/2d/Base/service/{z}/{x}/{y}.png'
    })
});

앞서 생성한 레이어들을 조합해 지도 객체를 생성하면 끝입니다.

var map = new Map({
    layers: [base, vector],
    target: document.getElementById('map'),
    view: new View({
        center:  [14128579.82, 4512570.74],
        zoom: 15
    })
});

속도가 빠른 이유는 2가지입니다. 첫째는 IE가 아닌 크롬에서 테스트 했다는 것인데, 그렇다고 IE에서 실행해보면 아예 실행이 안되는 것은 아니고 크롬보다 살짝 느릴 뿐입니다. 하지만 향후 IE가 크롬의 렌더링 기술을 사용할 것이므로 IE도 크롬만큼 빨라질 것으로 기대합니다. 두번째 이유는 OpenLayers의 지도 객체가 WebGLMap 타입이라는 것입니다. 가장 처음 언급된 코드의 모듈 추가에서 ol/Map.js 대신 ol/WebGLMap.js에서 Map 클래스를 가져왔기 때문입니다.

[OpenLayers] 현재 지도 화면 영역 얻기

지도를 이동 또는 확대 후에 지도가 표시되는 화면의 영역을 얻어야할 때가 있습니다. 여기서의 화면 영역의 좌표는 지도 좌표입니다. 이 글은 지도가 이동 또는 확대 시 발생되는 이벤트를 통해서.. 변경된 지도 화면에 대한 지도 좌표 영역을 얻어서 지도 상에 사각형으로 그려주는 예를 소개합니다.

먼저 필요한 index.html 파일의 내용입니다.



    
        
        OpenLayers
        
    
                    
        

그리고 index.js 파일에 대한 내용인데, 순차적으로 하나씩 언급하면.. 먼저 필요한 모듈의 추가입니다.

import 'ol/ol.css';
 
import Map from 'ol/Map.js';
import View from 'ol/View.js';
import {getBottomLeft, getTopRight, getBottomRight, getTopLeft} from 'ol/extent.js';
import {toLonLat} from 'ol/proj.js';
import OSM from 'ol/source/OSM.js';
import {Vector as VectorSource} from 'ol/source.js';
import Feature from 'ol/Feature.js';
import {LineString} from 'ol/geom.js';
import {Stroke, Style} from 'ol/style.js';
import {Tile as TileLayer, Vector as VectorLayer} from 'ol/layer.js';
import {get as getProjection} from 'ol/proj.js';

현재 화면 영역에 대한 사각형 도형을 추가하기 위한 벡터 레이어가 필요하므로 이를 위한 레이어 생성과 이 레이어로 구성된 지도를 생성하는 코드입니다.

var vectorsource = new VectorSource();

var vectorlayer = new VectorLayer({
    source: vectorsource,
    style: new Style({
        stroke: new Stroke({
            width: 2,
            color: [0, 0, 255, 1]
        })
    })
});

var map = new Map({
    layers: [
        new TileLayer({
            source: new OSM()
        }),
        vectorlayer
    ],
    target: 'map',
    view: new View({
        center: [0, 0],
        zoom: 18
    })
});

사용자가 지도를 확대 또는 이동하면 지도에 대해서 moveend 이벤트가 발생하는데, 이 이벤트에 대한 콜백 함수를 지정합니다.

map.on('moveend', onMoveEnd);

function onMoveEnd(evt) {
    var map = evt.map;
    var size = map.getSize();
    var extent = map.getView().calculateExtent(size);
    var bottomLeft = toLonLat(getBottomLeft(extent));
    var topRight = toLonLat(getTopRight(extent));
    var bottomRight = toLonLat(getBottomRight(extent));
    var topLeft = toLonLat(getTopLeft(extent));

    var box = new Feature(new LineString(
        [
            [bottomLeft[0], bottomLeft[1]], 
            [bottomRight[0], bottomRight[1]], 
            [topRight[0], topRight[1]],
            [topLeft[0], topLeft[1]],
            [bottomLeft[0], bottomLeft[1]]
        ])
    );

    var current_projection = getProjection('EPSG:4326');
    var new_projection = getProjection('EPSG:3857');
 
    box.getGeometry().transform(current_projection, new_projection);

    vectorsource.addFeatures([box]);
}

이 글의 핵심적인 내용이니 하나씩 파악해보면… 5번 코드의 map에 대한 getSize()를 통해 얻어올 수 있는 것은 현재 지도에 대한 픽셀 단위의 화면 크기입니다. 이 화면 크기를 이용해 map의 view에 대한 calculateExtent 함수의 인자로 전달해 해당 크기만큼의 현재 지도 화면의 지도 좌표의 경계(MinX, MinY, MaxX, MaxY)를 얻는 코드가 6입니다. 이렇게 얻은 지도 좌표 경계는 사각형 형태인데 각 4개의 모서리 좌표를 얻는 것이 7-10번 코드입니다. 그런데 이 코드들을 보면 좌표를 경위도로 변경하고 있는데.. 이는 OSM의 좌표계인 EPSG:4326으로 넘어오기 때문에 이를 WGS84 타원체의 경위도 좌표계(EPSG:3857)로 변환하는 것입니다. 사실 그냥 표현만을 위한 것이라면 OSM 좌표계로 그대로 사용해도 되지만 DBMS에 저장시에는 경위도 좌표계가 필요한 경우가 많아 소개해 봅니다. 이렇게 얻은 좌표를 이용해 Feature를 생성하고, 이를 다시 화면에 표시하기 위해 EPSG:3857에서 EPSG:4326으로 변환합니다. ^^; 역시 좌표계 변환을 방법을 소개 및 정리하기 위해 추가해본 코드입니다. 이처럼 좌표변환까지 완료된 Feature를 소스에 추가해 주면 됩니다.