From f703b6ff901c7983daddf80f304566892e127dca Mon Sep 17 00:00:00 2001 From: Lorenzo Natali Date: Tue, 6 Dec 2016 10:45:24 +0100 Subject: [PATCH] Fix #1314 Support to print vector layer (#1317) * Fix #1314 Support to print vector layer (leaflet) - Support vector style for leaflet layers - Conversion to OL2 style format - add reprojectGeoJson utility method to CoordinateUtils Missing: - Print preview support - Markers * Add default marker and preview for vector print - Now you can print also markers - Now the preview shows the vector layer **note**: a little bug have to be solved. The preview map do not show features on first rendering. This can be avoided loading them in the VectorLayer on startup, but this will cause duplicated vectors data. * fixed preview issues for same named layer * ForceUpdate when layer's created. Optimized leaflet layer rendering * rounded reprojection coordinates tests * add tests for feature and vector layers --- web/client/components/map/leaflet/Feature.jsx | 2 +- web/client/components/map/leaflet/Layer.jsx | 22 ++++ .../map/leaflet/__tests__/Layer-test.jsx | 115 ++++++++++++++++++ .../components/map/openlayers/Layer.jsx | 1 + web/client/components/print/MapPreview.jsx | 25 +++- web/client/utils/CoordinatesUtils.js | 92 +++++++++++++- web/client/utils/PrintUtils.js | 110 +++++++++++++++++ .../utils/__tests__/CoordinatesUtils-test.js | 30 +++++ web/client/utils/__tests__/PrintUtils-test.js | 96 ++++++++++++++- web/client/utils/openlayers/StyleUtils.js | 8 ++ 10 files changed, 493 insertions(+), 8 deletions(-) diff --git a/web/client/components/map/leaflet/Feature.jsx b/web/client/components/map/leaflet/Feature.jsx index 17f2d21e23..678cd9efc1 100644 --- a/web/client/components/map/leaflet/Feature.jsx +++ b/web/client/components/map/leaflet/Feature.jsx @@ -107,7 +107,7 @@ var geometryToLayer = function(geojson, options) { return new L.FeatureGroup(layers); case 'GeometryCollection': for (i = 0, len = geometry.geometries.length; i < len; i++) { - layer = this.geometryToLayer({ + layer = geometryToLayer({ geometry: geometry.geometries[i], type: 'Feature', properties: geojson.properties diff --git a/web/client/components/map/leaflet/Layer.jsx b/web/client/components/map/leaflet/Layer.jsx index 878406d19c..38c561a39f 100644 --- a/web/client/components/map/leaflet/Layer.jsx +++ b/web/client/components/map/leaflet/Layer.jsx @@ -8,6 +8,7 @@ var React = require('react'); var Layers = require('../../../utils/leaflet/Layers'); var assign = require('object-assign'); +var {isEqual} = require('lodash'); const LeafletLayer = React.createClass({ propTypes: { @@ -45,6 +46,26 @@ const LeafletLayer = React.createClass({ } this.updateLayer(newProps, this.props); }, + shouldComponentUpdate(newProps) { + // the reduce returns true when a prop is changed + // optimizing when options are equal ignorning loading key + return !(["map", "type", "srs", "position", "zoomOffset", "onInvalid", "onClick", "options"].reduce( (prev, p) => { + switch (p) { + case "map": + case "type": + case "srs": + case "position": + case "zoomOffset": + case "onInvalid": + case "onClick": + return prev && this.props[p] === newProps[p]; + case "options": + return prev && (this.props[p] === newProps[p] || isEqual({...this.props[p], loading: false}, {...newProps[p], loading: false})); + default: + return prev; + } + }, true)); + }, componentWillUnmount() { if (this.layer && this.props.map) { this.removeLayer(); @@ -105,6 +126,7 @@ const LeafletLayer = React.createClass({ this.layer.layerName = options.name; this.layer.layerId = options.id; } + this.forceUpdate(); } }, updateLayer(newProps, oldProps) { diff --git a/web/client/components/map/leaflet/__tests__/Layer-test.jsx b/web/client/components/map/leaflet/__tests__/Layer-test.jsx index 620ba1d2d2..25e75909f2 100644 --- a/web/client/components/map/leaflet/__tests__/Layer-test.jsx +++ b/web/client/components/map/leaflet/__tests__/Layer-test.jsx @@ -9,6 +9,7 @@ var React = require('react/addons'); var ReactDOM = require('react-dom'); var L = require('leaflet'); var LeafLetLayer = require('../Layer.jsx'); +var Feature = require('../Feature.jsx'); var expect = require('expect'); require('../../../../utils/leaflet/Layers'); @@ -18,6 +19,7 @@ require('../plugins/WMSLayer'); require('../plugins/GoogleLayer'); require('../plugins/BingLayer'); require('../plugins/MapQuest'); +require('../plugins/VectorLayer'); describe('Leaflet layer', () => { let map; @@ -177,6 +179,119 @@ describe('Leaflet layer', () => { expect(urls.length).toBe(1); }); + it('creates a vector layer for leaflet map', () => { + var options = { + "type": "wms", + "visibility": true, + "name": "vector_sample", + "group": "sample", + "features": [ + { "type": "Feature", + "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, + "properties": {"prop0": "value0"} + }, + { "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0] + ] + }, + "properties": { + "prop0": "value0", + "prop1": 0.0 + } + }, + { "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ [100.0, 0.0], [101.0, 0.0], [101.0, 1.0], + [100.0, 1.0], [100.0, 0.0] ] + ] + }, + "properties": { + "prop0": "value0", + "prop1": {"this": "that"} + } + }, + { "type": "Feature", + "geometry": { "type": "MultiPoint", + "coordinates": [ [100.0, 0.0], [101.0, 1.0] ] + }, + "properties": { + "prop0": "value0", + "prop1": {"this": "that"} + } + }, + { "type": "Feature", + "geometry": { "type": "MultiLineString", + "coordinates": [ + [ [100.0, 0.0], [101.0, 1.0] ], + [ [102.0, 2.0], [103.0, 3.0] ] + ] + }, + "properties": { + "prop0": "value0", + "prop1": {"this": "that"} + } + }, + { "type": "Feature", + "geometry": { "type": "MultiPolygon", + "coordinates": [ + [[[102.0, 2.0], [103.0, 2.0], [103.0, 3.0], [102.0, 3.0], [102.0, 2.0]]], + [[[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]], + [[100.2, 0.2], [100.8, 0.2], [100.8, 0.8], [100.2, 0.8], [100.2, 0.2]]] + ] + }, + "properties": { + "prop0": "value0", + "prop1": {"this": "that"} + } + }, + { "type": "Feature", + "geometry": { "type": "GeometryCollection", + "geometries": [ + { "type": "Point", + "coordinates": [100.0, 0.0] + }, + { "type": "LineString", + "coordinates": [ [101.0, 0.0], [102.0, 1.0] ] + } + ] + }, + "properties": { + "prop0": "value0", + "prop1": {"this": "that"} + } + } + ] + }; + // create layers + var layer = ReactDOM.render( + ( + {options.features.map((feature) => ())}), document.getElementById("container")); + expect(layer).toExist(); + let l2 = ReactDOM.render( + ( + {options.features.map((feature) => ())}), document.getElementById("container")); + expect(l2).toExist(); + }); + it('creates a wms layer for leaflet map with custom tileSize', () => { var options = { "type": "wms", diff --git a/web/client/components/map/openlayers/Layer.jsx b/web/client/components/map/openlayers/Layer.jsx index 63a1d55ed5..e4e74f5e5b 100644 --- a/web/client/components/map/openlayers/Layer.jsx +++ b/web/client/components/map/openlayers/Layer.jsx @@ -104,6 +104,7 @@ const OpenlayersLayer = React.createClass({ if (this.layer && !this.layer.detached) { this.addLayer(options); } + this.forceUpdate(); } }, updateLayer(newProps, oldProps) { diff --git a/web/client/components/print/MapPreview.jsx b/web/client/components/print/MapPreview.jsx index 13e95d6dc4..abf505dddf 100644 --- a/web/client/components/print/MapPreview.jsx +++ b/web/client/components/print/MapPreview.jsx @@ -14,6 +14,7 @@ const {Button, Glyphicon} = require('react-bootstrap'); let PMap; let Layer; +let Feature; const MapPreview = React.createClass({ propTypes: { @@ -54,6 +55,7 @@ const MapPreview = React.createClass({ PMap = require('../map/' + this.props.mapType + '/Map'); Layer = require('../map/' + this.props.mapType + '/Layer'); require('../map/' + this.props.mapType + '/plugins/index'); + Feature = require('../map/' + this.props.mapType + '/index').Feature; }, getRatio() { if (this.props.width && this.props.layoutSize && this.props.resolutions) { @@ -77,6 +79,22 @@ const MapPreview = React.createClass({ }) }); }, + renderLayerContent(layer) { + if (layer.features && layer.type === "vector") { + return layer.features.map( (feature) => { + return ( + + ); + }); + } + return null; + }, render() { const style = assign({}, this.props.style, { width: this.props.width + "px", @@ -102,8 +120,11 @@ const MapPreview = React.createClass({ mapOptions={mapOptions} > {this.props.layers.map((layer, index) => - + + {this.renderLayerContent(layer)} + + )} {this.props.enableScalebox ? = 2 && + typeof list[0] === 'number' && + typeof list[1] === 'number'; +} +function traverseCoords(coordinates, callback) { + if (isXY(coordinates)) return callback(coordinates); + return coordinates.map(function(coord) { return traverseCoords(coord, callback); }); +} -var CoordinatesUtils = { +function traverseGeoJson(geojson, leafCallback, nodeCallback) { + if (geojson === null) return geojson; + + let r = cloneDeep(geojson); + + if (geojson.type === 'Feature') { + r.geometry = traverseGeoJson(geojson.geometry, leafCallback, nodeCallback); + } else if (geojson.type === 'FeatureCollection') { + r.features = r.features.map(function(gj) { return traverseGeoJson(gj, leafCallback, nodeCallback); }); + } else if (geojson.type === 'GeometryCollection') { + r.geometries = r.geometries.map(function(gj) { return traverseGeoJson(gj, leafCallback, nodeCallback); }); + } else { + if (leafCallback) leafCallback(r); + } + + if (nodeCallback) nodeCallback(r); + + return r; +} + +function determineCrs(crs) { + if (typeof crs === 'string' || crs instanceof String) { + return Proj4js.defs(crs) ? new Proj4js.Proj(crs) : null; + } + return crs; +} + +const CoordinatesUtils = { getUnits: function(projection) { const proj = new Proj4js.Proj(projection); return proj.units || 'degrees'; @@ -27,6 +65,52 @@ var CoordinatesUtils = { } return null; }, + /** + * Reprojects a geojson from a crs into another + */ + reprojectGeoJson: function(geojson, fromParam = "EPSG:4326", toParam = "EPSG:4326") { + let from = fromParam; + let to = toParam; + if (typeof from === 'string') { + from = determineCrs(from); + } + if (typeof to === 'string') { + to = determineCrs(to); + } + let transform = proj4(from, to); + + return traverseGeoJson(geojson, (gj) => { + // No easy way to put correct CRS info into the GeoJSON, + // and definitely wrong to keep the old, so delete it. + if (gj.crs) { + delete gj.crs; + } + gj.coordinates = traverseCoords(gj.coordinates, (xy) => { + return transform.forward(xy); + }); + }, (gj) => { + if (gj.bbox) { + // A bbox can't easily be reprojected, just reprojecting + // the min/max coords definitely will not work since + // the transform is not linear (in the general case). + // Workaround is to just re-compute the bbox after the + // transform. + gj.bbox = (() => { + let min = [Number.MAX_VALUE, Number.MAX_VALUE]; + let max = [-Number.MAX_VALUE, -Number.MAX_VALUE]; + traverseGeoJson(gj, function(_gj) { + traverseCoords(_gj.coordinates, function(xy) { + min[0] = Math.min(min[0], xy[0]); + min[1] = Math.min(min[1], xy[1]); + max[0] = Math.max(max[0], xy[0]); + max[1] = Math.max(max[1], xy[1]); + }); + }); + return [min[0], min[1], max[0], max[1]]; + })(); + } + }); + }, normalizePoint: function(point) { return { x: point.x || 0.0, diff --git a/web/client/utils/PrintUtils.js b/web/client/utils/PrintUtils.js index 0397ded09b..9aa9ab4be1 100644 --- a/web/client/utils/PrintUtils.js +++ b/web/client/utils/PrintUtils.js @@ -9,6 +9,7 @@ const CoordinatesUtils = require('./CoordinatesUtils'); const MapUtils = require('./MapUtils'); + const {isArray} = require('lodash'); const url = require('url'); @@ -17,6 +18,10 @@ const defaultScales = MapUtils.getGoogleMercatorScales(0, 21); const assign = require('object-assign'); +const getGeomType = function(layer) { + return (layer.features && layer.features[0]) ? layer.features[0].geometry.type : undefined; +}; + const PrintUtils = { normalizeUrl: (input) => { let result = isArray(input) ? input[0] : input; @@ -140,6 +145,23 @@ const PrintUtils = { ] }) }, + vector: { + map: (layer, spec) => ({ + type: 'Vector', + name: layer.name, + "opacity": layer.opacity || 1.0, + styleProperty: "ms_style", + styles: { + 1: PrintUtils.toOpenLayers2Style(layer, layer.style) + }, + geoJson: CoordinatesUtils.reprojectGeoJson({ + type: "FeatureCollection", + features: layer.features.map( f => ({...f, properties: {...f.properties, ms_style: 1}})) + }, + "EPSG:4326", + spec.projection) + }) + }, osm: { map: () => ({ "baseURL": "http://a.tile.openstreetmap.org/", @@ -220,6 +242,94 @@ const PrintUtils = { ] }) } + }, + /** + * Useful for print (Or generic Openlayers 2 conversion style) + */ + toOpenLayers2Style: function(layer, style) { + if (!style) { + return PrintUtils.getOlDefaultStyle(layer); + } + // commented the available options. + return { + "fillColor": style.fillColor, + "fillOpacity": style.fillOpacity, + // "rotation": "30", + "externalGraphic": style.iconUrl, + // "graphicName": "circle", + // "graphicOpacity": 0.4, + "pointRadius": style.radius, + "strokeColor": style.color, + "strokeOpacity": style.opacity, + "strokeWidth": style.weight + // "strokeLinecap": "round", + // "strokeDashstyle": "dot", + // "fontColor": "#000000", + // "fontFamily": "sans-serif", + // "fontSize": "12px", + // "fontStyle": "normal", + // "fontWeight": "bold", + // "haloColor": "#123456", + // "haloOpacity": "0.7", + // "haloRadius": "3.0", + // "label": "${name}", + // "labelAlign": "cm", + // "labelRotation": "45", + // "labelXOffset": "-25.0", + // "labelYOffset": "-35.0" + }; + }, + /** + * Provides the default style for + * each vector type. + */ + getOlDefaultStyle(layer) { + switch (getGeomType(layer)) { + case 'Polygon': + case 'MultiPolygon': { + return { + "fillColor": "#0000FF", + "fillOpacity": 0.1, + "strokeColor": "#0000FF", + "strokeOpacity": 1, + "strokeWidth": 3 + }; + } + case 'MultiLineString': + case 'LineString': + return { + "strokeColor": "#0000FF", + "strokeOpacity": 1, + "strokeWidth": 3 + }; + case 'Point': + case 'MultiPoint': { + return layer.styleName === "marker" ? { + "externalGraphic": "http://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/images/marker-icon.png", + "graphicWidth": 25, + "graphicHeight": 41, + "graphicXOffset": -12, // different offset + "graphicYOffset": -41 + } : { + "fillColor": "#FF0000", + "fillOpacity": 0, + "strokeColor": "#FF0000", + "pointRadius": 5, + "strokeOpacity": 1, + "strokeWidth": 1 + }; + } + default: { + return { + "fillColor": "#0000FF", + "fillOpacity": 0.1, + "strokeColor": "#0000FF", + "pointRadius": 5, + "strokeOpacity": 1, + "strokeWidth": 1 + }; + } + } } }; diff --git a/web/client/utils/__tests__/CoordinatesUtils-test.js b/web/client/utils/__tests__/CoordinatesUtils-test.js index 3547e33946..c007b8e0fe 100644 --- a/web/client/utils/__tests__/CoordinatesUtils-test.js +++ b/web/client/utils/__tests__/CoordinatesUtils-test.js @@ -72,4 +72,34 @@ describe('CoordinatesUtils', () => { expect(CoordinatesUtils.getCompatibleSRS('EPSG:3857', {'EPSG:900913': true, 'EPSG:3857': true})).toBe('EPSG:3857'); expect(CoordinatesUtils.getCompatibleSRS('EPSG:3857', {'EPSG:3857': true})).toBe('EPSG:3857'); }); + it('test reprojectGeoJson', () => { + const testPoint = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + -112.50042920000001, + 42.22829164089942 + ] + }, + properties: { + "serial_num": "12C324776" + }, + id: 0 + } + ] + }; + const reprojectedTestPoint = CoordinatesUtils.reprojectGeoJson(testPoint, "EPSG:4326", "EPSG:900913"); + expect(reprojectedTestPoint).toExist(); + expect(reprojectedTestPoint.features).toExist(); + expect(reprojectedTestPoint.features[0]).toExist(); + expect(reprojectedTestPoint.features[0].type).toBe("Feature"); + expect(reprojectedTestPoint.features[0].geometry.type).toBe("Point"); + // approximate values should be the same + expect(reprojectedTestPoint.features[0].geometry.coordinates[0].toFixed(4)).toBe((-12523490.492568726).toFixed(4)); + expect(reprojectedTestPoint.features[0].geometry.coordinates[1].toFixed(4)).toBe((5195238.005360028).toFixed(4)); + }); }); diff --git a/web/client/utils/__tests__/PrintUtils-test.js b/web/client/utils/__tests__/PrintUtils-test.js index 2cf2a9cde3..6b070e44db 100644 --- a/web/client/utils/__tests__/PrintUtils-test.js +++ b/web/client/utils/__tests__/PrintUtils-test.js @@ -15,7 +15,74 @@ const layer = { params: {myparam: "myvalue"} }; - +const vectorLayer = { + "type": "vector", + "visibility": true, + "group": "Local shape", + "id": "web2014all_mv__14", + "name": "web2014all_mv", + "hideLoading": true, + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -112.50042920000001, + 42.22829164089942 + ] + }, + "properties": { + "serial_num": "12C324776" + }, + "id": 0 + } + ], + "style": { + "weight": 3, + "radius": 10, + "opacity": 1, + "fillOpacity": 0.1, + "color": "rgb(0, 0, 255)", + "fillColor": "rgb(0, 0, 255)" + } +}; +const mapFishVectorLayer = { + "type": "Vector", + "name": "web2014all_mv", + "opacity": 1, + "styleProperty": "ms_style", + "styles": { + "1": { + "fillColor": "rgb(0, 0, 255)", + "fillOpacity": 0.1, + "pointRadius": 10, + "strokeColor": "rgb(0, 0, 255)", + "strokeOpacity": 1, + "strokeWidth": 3 + } + }, + "geoJson": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -12523490.492568726, + 5195238.005360028 + ] + }, + "properties": { + "serial_num": "12C324776", + "ms_style": 1 + }, + "id": 0 + } + ] + } +}; describe('PrintUtils', () => { it('custom params are applied to wms layers', () => { @@ -26,4 +93,31 @@ describe('PrintUtils', () => { expect(specs[0].customParams.myparam).toExist(); expect(specs[0].customParams.myparam).toBe("myvalue"); }); + it('vector layer generation for print', () => { + const specs = PrintUtils.getMapfishLayersSpecification([vectorLayer], {projection: "EPSG:3857"}, 'map'); + expect(specs).toExist(); + expect(specs.length).toBe(1); + expect(specs[0].geoJson.features[0].geometry.coordinates[0], mapFishVectorLayer).toBe(mapFishVectorLayer.geoJson.features[0].geometry.coordinates[0]); + }); + it('vector layer default point style', () => { + const style = PrintUtils.getOlDefaultStyle({features: [{geometry: {type: "Point"}}]}); + expect(style).toExist(); + expect(style.pointRadius).toBe(5); + }); + it('vector layer default marker style', () => { + const style = PrintUtils.getOlDefaultStyle({styleName: "marker", features: [{geometry: {type: "Point"}}]}); + expect(style).toExist(); + expect(style.externalGraphic).toExist(); + }); + it('vector layer default polygon style', () => { + const style = PrintUtils.getOlDefaultStyle({features: [{geometry: {type: "Polygon"}}]}); + expect(style).toExist(); + expect(style.strokeWidth).toBe(3); + + }); + it('vector layer default line style', () => { + const style = PrintUtils.getOlDefaultStyle({features: [{geometry: {type: "LineString"}}]}); + expect(style).toExist(); + expect(style.strokeWidth).toBe(3); + }); }); diff --git a/web/client/utils/openlayers/StyleUtils.js b/web/client/utils/openlayers/StyleUtils.js index 5d18027f74..2ddd6d5cee 100644 --- a/web/client/utils/openlayers/StyleUtils.js +++ b/web/client/utils/openlayers/StyleUtils.js @@ -20,6 +20,14 @@ const toVectorStyle = function(layer, style) { if (style.marker && (geomT === 'Point' || geomT === 'MultiPoint')) { newLayer.styleName = "marker"; }else { + newLayer.style = { + weight: style.width, + radius: style.radius, + opacity: style.color.a, + fillOpacity: style.fill.a, + color: getColor(style.color), + fillColor: getColor(style.fill) + }; let stroke = new ol.style.Stroke({ color: getColor(style.color), width: style.width