[OpenLayers] 지도 화면을 PDF 파일로 저장하기

웹에서 PDF 파일을 생성하는 것은 클라이언트가 서버에 PDF 파일 생성을 요청하고 서버측에서 생성한 PDF 파일을 클라이언트에서는 다운로드 하는 방식으로 이루어졌다. 하지만 서버의 기술 없이도 클라이언트에서 PDF 파일을 생성해 내려받을 수 있는데, 이를 ol의 지도를 PDF 파일로 생성하는 것을 토대로 정리해 본다.

웹에서 JS만으로 PDF를 생성하는 API로 많이 사용되는 jspdf.js 라이브러리가 있는데 이를 이용한다.

먼저 index.html에 UI 요소에 대한 코드를 다음처럼 작성한다.



    
        
        OpenLayers
        

        
    
                    
        

jspdf.js 스크립트에 대한 추가와 DOM 요소로써 PDF로 출력할 지도에 대한 DIV 및 PDF 출력을 위한 버튼이 보인다. index.js에 JS 코드를 작성할 것인데, 하나씩 살펴보자.

먼저 필요한 모듈을 추가한다.

import 'ol/ol.css';
 
import Map from 'ol/Map.js';
import View from 'ol/View.js';
import {Tile as TileLayer, Vector as VectorLayer} from 'ol/layer.js';
import {OSM, Vector as VectorSource} from 'ol/source.js';

다음은 출력할 지도 객체에 대한 코드이다.

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

이제 버튼을 클릭하면 해당 지도의 내용이 PDF로 출력되는데, 이를 위해 필요한 변수이다.

var format = 'a4';

/*  Unit : mm
    A0: [1189, 841]
    A1:  [841, 594]
    A2:  [594, 420]
    A3:  [420, 297]
    A4:  [297, 210]
    A5:  [210, 148]
*/
var dim = [297, 210];

var resolution = 150; //dpi (Unit: inch)

지도를 A4 용지 크기로 PDF 출력할 것이고, A4 용지의 크기는 397mm x 210mm 라는 점. 그리고 출력 DPI 해상도는 150으로 지정했는데, 이를 높이면 그만큼 출력 품질은 올라가지만 더 많은 CPU와 메모리 자원을 사용하고 생성하는 시간도 상당이 길어진다. 다음은 출력 버튼의 클릭 이벤트에 대한 코드이다.

var btn = document.querySelector('#btn');

btn.addEventListener('click', function() {
    btn.disabled = true;
    document.body.style.cursor = 'progress';

    var width = Math.round(dim[0] * resolution / 25.4); // 1in = 25.4mm
    var height = Math.round(dim[1] * resolution / 25.4);
    var size = /** @type {module:ol/size~Size} */ (map.getSize());
    var extent = map.getView().calculateExtent(size);

    map.once('rendercomplete', function(event) {
        var canvas = event.context.canvas;
        var data = canvas.toDataURL('image/jpeg');
        var pdf = new jsPDF({
            orientation: 'landscape',
            unit: 'mm',
            format: format
        });

        pdf.addImage(data, 'JPEG', 0, 0, dim[0], dim[1]);
        pdf.save('map.pdf');
        
        // Reset original map size
        map.setSize(size);
        map.getView().fit(extent, {size: size});
        
        btn.disabled = false;
        document.body.style.cursor = 'auto';
    });

    // Set print size
    var printSize = [width, height];
    map.setSize(printSize);
    map.getView().fit(extent, {size: printSize});
}, false);

위의 코드를 이해하기 위해서는 먼저 jspdf.js 라이브러리의 사용법을 익히는게 필요하다. 중요한 부분만을 언급하면 7~10번은 앞서 A4 용지 크기만큼 지도를 출력하기 위해서는 A4 용지의 크기만큼 지도를 다시 그려줘야하는데 그려줄 영역의 크기를 계산하고 이 크기를 33~35번 코드처럼 지도 객체에 설정하게 되면 지도가 그려지게 시작하면서 다 그려지면 rendercomplete 이벤트가 발생한다. 이 이벤트에 대한 호출 함수는 12번에서 정의되어 있는데 여기서 jsPDF 라이브러리를 사용하는 코드가 보인다. jsPDF를 통해 PDF 출력이 끝나면 다시 지도를 원래의 크기로 되돌리기 위해 25~26번 코드의 호출이 필요하다.

[OpenLayers] 지오메트리(Geometry)에 대한 공간 연산(JSTS.js 사용)

ol에서 지오메트리에 대해 buffer나 union 등과 같은 공간 연산 기능에 대한 API를 정리합니다. 공간 연산 기능은 JSTS.js라는 별도의 라이브러리를 통해 수행합니다. JSTS.js는 Java의 JTS 라이브러리를 Javascript 언어로 포팅한 라이브러리입니다. 이글은 공간 연산을 위한 지오메트리를 생성하기 위해 “마우스로 Vector 생성하기(그리기)”라는 글의 코드를 약간 변형하여 사용합니다. ol에서 마우스로 도형을 그리는 API의 설명은 이 글을 참고 하기 바랍니다.

먼저 실행 결과를 아래의 동영상을 통해 살펴볼 수 있습니다.

사용자가 Buffer 연산을 적용할 지오메트리를 그리면 바로 200m 만큼 Buffer 연산이 적용되어 화면에 표시됩니다. 이를 위해 먼저 index.html 파일을 아래처럼 구성합니다.



    
        
        OpenLayers
        

        
    
    
        

20번 라인에 jsts.js 라이브러리 스크립트를 추가하고 있습니다. index.js 파일에 대해 설명하면 다음과 같습니다.

먼저 필요한 모듈을 추가합니다.

import 'ol/ol.css';
 
import Map from 'ol/Map.js';
import View from 'ol/View.js';
import Draw from 'ol/interaction/Draw.js';
import {Tile as TileLayer, Vector as VectorLayer} from 'ol/layer.js';
import {OSM, Vector as VectorSource} from 'ol/source.js';
import WKT from 'ol/format/WKT.js';

그리고 레이어와 지도를 구성합니다.

var raster = new TileLayer({
    source: new OSM()
});

var source = new VectorSource({wrapX: false});
 
var vector = new VectorLayer({
    source: source
});

var map = new Map({
    layers: [raster, vector],
    target: 'map',
    view: new View({
        center: [-11000000, 4600000],
        zoom: 15
    })
});

다음으로 그리고자 하는 도형의 종류를 선택하는 UI의 이벤트 처리와 도형을 그리는 Draw 클래스 타입을 위한 draw 변수를 정의합니다.

var typeSelect = document.getElementById('type');
 
typeSelect.onchange = function() {
    map.removeInteraction(draw);
    addInteraction();
};
 
addInteraction();

var draw;

지금까지는 “마우스로 Vector 생성하기(그리기)”의 내용과 큰 차이가 없습니다. 이제 변경된 가장 중요한 부분인 addInteraction 함수는 아래와 같습니다.

function addInteraction() {
    var value = typeSelect.value;
 
    if (value !== 'None') {
        draw = new Draw({
            source: source,
            type: typeSelect.value,
            freehand: true
        });

        draw.on('drawend', function(e) {
            var format = new WKT();
            var feature = e.feature;
            var wkt = format.writeFeature(feature);
            var reader = new jsts.io.WKTReader()
            var jstsGeom = reader.read(wkt)
            var buffered = jstsGeom.buffer(200);
            var writer = new jsts.io.WKTWriter()
            var bufferedWKT = writer.write(buffered);
            var bufferedFeature = format.readFeature(bufferedWKT);

            feature.setGeometry(bufferedFeature.getGeometry());
        });

        map.addInteraction(draw);
    }
}

마우스를 통한 도형 그리기가 완료되면 11번 코드에 등록된 drawend 이벤트 함수가 호출됩니다. 이 이벤트 함수에서 그려진 도형을 WKT로 변환하고 JSTS.js에서 해석할 수 있는 지오메트로로 변환한 후 Buffer 연산을 수행합니다. 그리고 이를 다시 WKT로 변환하고 ol의 지오메트리로 변환하는 과정을 거치게 됩니다. 실행하면 앞서 살펴본 영상과 동일한 결과를 볼 수 있습니다.

[OpenLayers] XYZ 방식의 타일 DEM으로 음영기복도(Shade) 레이어 만들기

타일 방식으로 제공되는 DEM을 이용해 음영기복도를 OpenLayers에서 동적으로 생성해 표시하는 내용을 설명한다.

음영기복도가 무었인지 이 글에서 작성할 코드를 통해 생성되는 최종 결과물을 통해 살펴보자. 아래와 같다.

DEM 데이터를 이용한 음영기복도의 생성은 동적으로 이루어지는데, DEM 데이터는 XYZ 방식의 타일맵으로 제공된다. 이 XYZ 방식의 타일맵을 통해 높이 값을 얻을 수 있다. 이렇게 얻은 높이값을 이용해 음영기복도를 위한 값을 동적으로, 즉 실행중에 계산하여 이미지를 만들어 낸다. 만들어진 이미지는 ImageLayer 클래스의 객체로써 레이어로 처리될 수 있으며, 레이어는 지도의 구성 단위이다. 또한 음영기복도 생성을 위한 연산을 Raster 연산이라고 한다. Raster 연산을 위해서는 Raster라는 데이터소스 클래스가 필요하다. 이처럼 지도(Map), ImageLayer, Raster, XYZ 방식의 타일맵 소스에 대한 클래스 관계도는 아래와 같다.

자, 이제 앞서 언급한 객체를 생성하고 조합해 보자. 먼저 필요한 모듈을 선언한다.

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

그리고 타일맵 방식으로 서비스 되는 DEM 데이터 소스에 대한 객체는 아래와 같다.

var elevation = new XYZ({
    url: 'https://{a-d}.tiles.mapbox.com/v3/aj.sf-dem/{z}/{x}/{y}.png',
    crossOrigin: 'anonymous',
    transition: 0
});

위의 elevation 데이터소스에 대한 Raster 연산 수행이 가능한 Raster 데이터소스 객체는 아래와 같다.

var raster = new Raster({
    sources: [elevation],
    operationType: 'image',
    operation: shade
});

Raster 연산에 대한 함수는 operation 속성으로 지정된 shade 함수이다. 이 shade 함수가 바로 음영기복도를 계산하여 그 결과를 이미지로 반환하는 함수인데, 아래와 같다. (음영기복도 생성을 위한 알고리즘에 대한 설명은 생략함)

function shade(inputs, data) {
    var elevationImage = inputs[0]; // 첫번째(0) 데이터소스로 elevation 객체
    var width = elevationImage.width;
    var height = elevationImage.height;
    var elevationData = elevationImage.data;
    var shadeData = new Uint8ClampedArray(elevationData.length);
    var dp = data.resolution * 2;
    var maxX = width - 1;
    var maxY = height - 1;
    var pixel = [0, 0, 0, 0];
    var twoPi = 2 * Math.PI;
    var halfPi = Math.PI / 2;
    var sunEl = Math.PI * data.sunEl / 180;
    var sunAz = Math.PI * data.sunAz / 180;
    var cosSunEl = Math.cos(data.sunEl);
    var sinSunEl = Math.sin(data.sunEl);
    var pixelX, pixelY, x0, x1, y0, y1, offset,
        z0, z1, dzdx, dzdy, slope, aspect, cosIncidence, scaled;

    for (pixelY = 0; pixelY <= maxY; ++pixelY) {
        y0 = pixelY === 0 ? 0 : pixelY - 1;
        y1 = pixelY === maxY ? maxY : pixelY + 1;
        for (pixelX = 0; pixelX <= maxX; ++pixelX) {
            x0 = pixelX === 0 ? 0 : pixelX - 1;
            x1 = pixelX === maxX ? maxX : pixelX + 1;

            // determine elevation for (x0, pixelY)
            offset = (pixelY * width + x0) * 4;
            pixel[0] = elevationData[offset];
            pixel[1] = elevationData[offset + 1];
            pixel[2] = elevationData[offset + 2];
            pixel[3] = elevationData[offset + 3];
            z0 = data.vert * (pixel[0] + pixel[1] * 2 + pixel[2] * 3);
        
            // determine elevation for (x1, pixelY)
            offset = (pixelY * width + x1) * 4;
            pixel[0] = elevationData[offset];
            pixel[1] = elevationData[offset + 1];
            pixel[2] = elevationData[offset + 2];
            pixel[3] = elevationData[offset + 3];
            z1 = data.vert * (pixel[0] + pixel[1] * 2 + pixel[2] * 3);

            dzdx = (z1 - z0) / dp;

            // determine elevation for (pixelX, y0)
            offset = (y0 * width + pixelX) * 4;
            pixel[0] = elevationData[offset];
            pixel[1] = elevationData[offset + 1];
            pixel[2] = elevationData[offset + 2];
            pixel[3] = elevationData[offset + 3];
            z0 = data.vert * (pixel[0] + pixel[1] * 2 + pixel[2] * 3);

            // determine elevation for (pixelX, y1)
            offset = (y1 * width + pixelX) * 4;
            pixel[0] = elevationData[offset];
            pixel[1] = elevationData[offset + 1];
            pixel[2] = elevationData[offset + 2];
            pixel[3] = elevationData[offset + 3];
            z1 = data.vert * (pixel[0] + pixel[1] * 2 + pixel[2] * 3);

            dzdy = (z1 - z0) / dp;

            slope = Math.atan(Math.sqrt(dzdx * dzdx + dzdy * dzdy));

            aspect = Math.atan2(dzdy, -dzdx);
            if (aspect < 0) {
                aspect = halfPi - aspect;
            } else if (aspect > halfPi) {
                aspect = twoPi - aspect + halfPi;
            } else {
                aspect = halfPi - aspect;
            }

            cosIncidence = sinSunEl * Math.cos(slope) +
                cosSunEl * Math.sin(slope) * Math.cos(sunAz - aspect);

            offset = (pixelY * width + pixelX) * 4;
            scaled = 255 * cosIncidence;
            shadeData[offset] = scaled;
            shadeData[offset + 1] = scaled;
            shadeData[offset + 2] = scaled;
            shadeData[offset + 3] = elevationData[offset + 3];
        }
    }

    return {data: shadeData, width: width, height: height};
}

위 코드의 shade 함수의 인자 중 data는 Raster 객체의 beforeoperations 이벤트에 의해 추가적으로 속성값이 추가될 수 있는데 아래와 같다.

raster.on('beforeoperations', function(event) {
    var data = event.data;

    data.resolution = event.resolution;
    data.vert = 1; // 음영기복도의 과고감 정도
    data.sunEl = 45; // 태양의 Elevation(단위: Degree)
    data.sunAz = 45; // 태양의 Azimuth(단위: Degree)
});

beforeoperations 이벤트는 Raster 연산이 실행되기 직전에 호출되는 함수이며, Raster 연산은 Raster의 데이터소스, 즉 XYZ를 통한 DEM 데이터를 받았을 때 호출된다.

이제 음영기복도에 대한 데이터소스에 대한 모든 정의가 완성되었으므로, 이를 화면에 표시하기 위한 레이어를 아래처럼 생성한다.

var imageLayer = new ImageLayer({ source: raster });

마지막으로 지도 객체를 생성하고 위의 imageLayer를 레이어로써 아래 코드처럼 지도에 추가한다.

var map = new Map({
    target: 'map',
    layers: [ imageLayer ],
    view: new View({
        extent: [-13675026, 4439648, -13580856, 4580292],
        center: [-13615645, 4497969],
        minZoom: 10,
        maxZoom: 16,
        zoom: 13
    })
});

[OpenLayers] 이미지 필터링

이미지 필터링은 이미지의 외곽선 추출이나 이미지의 잡음을 제거하기 위해 수행되는 NxN 행렬의 커널을 이미지의 각 화소에 연산하여 다시 조합하는 것을 말합니다. 이 글은 ol에서 받은 영상 데이터에 대한 외곽선을 추출하는 필터링 연산을 적용하여 그 결과를 실시간으로 살펴보는 것에 대한 내용을 정리합니다.

먼저 DOM을 아래처럼 구성합니다. 스타일과 함께 언급했고요..



    
        
        OpenLayers
        
    
    
        

지도에 대한 div 요소가 전부입니다. 이 div 요소가 바로 지도가 담길 DOM 입니다. js 코드를 순서대로 하나씩 살펴 보겠습니다. 먼저 필요한 모듈을 추가합니다.

import 'ol/ol.css';

import Map from 'ol/Map.js';
import View from 'ol/View.js';
import {Tile} from 'ol/layer.js';
import {XYZ} from 'ol/source.js';

그리고 필터링 대상이 되는 이미지를 VWorld의 항공영상 레이어로 사용할텐데.. 이에 대한 레이어 객체 imagery를 아래처럼 준비합니다.

let imagery = new Tile({
    source: new XYZ({
        url : 'http://xdworld.vworld.kr:8080/2d/Satellite/service/{z}/{x}/{y}.jpeg',
        crossOrigin: 'anonymous'
    }),
})

4번 코드를 보면 crossOrigin 옵션을 ‘anonymous’로 지정하고 있습니다. 이는 외부에서 가져온 이미지를 처리하여 다시 화면에 그릴때 발생하는 보안상의 오류를 방지합니다. 이제 생서한 레이어를 이용하여 지도 객체를 아래처럼 준비합니다.

let map = new Map({
    target: 'map',
    layers: [ 
        imagery
    ],
    view: new View({
        center:  [14128579.82, 4512570.74],
        zoom: 17,
        minZoom: 6
    })
});

외곽선 추출을 위한 커널로 3×3 행렬을 사용합니다. 가장 일반적인 외곽선 추출을 위한 3×3 행렬을 아래처럼 정의합니다.

let kernel = 
    [
        0,  1,  0,
        1, -4,  1,
        0,  1,  0
    ];

입력 데이터가 되는 항공영상 레이어에 postcompose 이벤트를 추가하여 입력 데이터가 완전이 준비되면 호출할 이벤트를 아래처럼 입력합니다.

imagery.on('postcompose', function(event) {
    convolve(event.context, kernel);
});

convolve 함수는 2개의 인자를 취하는데, 첫번째는 Canvas에 대한 context이고 두번째는 커널 행렬입니다. 첫번째 context의 Canvas에 입력 데이터에 대한 화소(Pixel 값으로써의 RGB)를 가지고 있습니다. 또한 이 Canvas에 다시 우리가 원하는 무언가를 그릴 수 있는데, 그리고자 하는 것은 필터링이 적용된 결과 이미지가 됩니다. convolve 함수는 다음과 같습니다.

function convolve(context, kernel) {
    let canvas = context.canvas;
    let width = canvas.width;
    let height = canvas.height;

    let size = Math.sqrt(kernel.length);
    let half = Math.floor(size / 2);

    let inputData = context.getImageData(0, 0, width, height).data;

    let output = context.createImageData(width, height);
    let outputData = output.data;

    for (let pixelY = 0; pixelY < height; ++pixelY) {
        let pixelsAbove = pixelY * width;
        for (let pixelX = 0; pixelX < width; ++pixelX) {
            let r = 0, g = 0, b = 0, a = 0;
            for (let kernelY = 0; kernelY < size; ++kernelY) {
                for (let kernelX = 0; kernelX < size; ++kernelX) {
                    let weight = kernel[kernelY * size + kernelX];
                    let neighborY = Math.min(height - 1, Math.max(0, pixelY + kernelY - half));
                    let neighborX = Math.min(width - 1, Math.max(0, pixelX + kernelX - half));
                    let inputIndex = (neighborY * width + neighborX) * 4;

                    r += inputData[inputIndex] * weight;
                    g += inputData[inputIndex + 1] * weight;
                    b += inputData[inputIndex + 2] * weight;
                    a += inputData[inputIndex + 3] * weight;
                }
            }

            let outputIndex = (pixelsAbove + pixelX) * 4;
            
            outputData[outputIndex] = r;
            outputData[outputIndex + 1] = g;
            outputData[outputIndex + 2] = b;
            outputData[outputIndex + 3] = 255;
        }
    }

    context.putImageData(output, 0, 0);
}

위의 코드의 핵심을 짚어보면 입력 데이터의 각 화소에 접근하여 커널 행렬을 적용하고, 그 결과 화소로 구성된 이미지를 다시 그려준다는 것입니다. 실행 결과는 아래와 같습니다.

위의 지역은 어딜까요...? 외곽선을 좀더 두드러지게 추출할 수 있는 다른 커널 행렬을 적용해 볼 필요가 있을듯합니다.