diff --git a/jest.config.js b/jest.config.js index 30e85ea..5165726 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,7 @@ module.exports = { moduleFileExtensions: [ - 'js' + 'js', + 'ts' ], moduleDirectories: [ 'node_modules' @@ -10,7 +11,11 @@ module.exports = { ], collectCoverage: true, collectCoverageFrom: [ - 'src/**/*.js' + 'src/**/*.ts' ], + transform: { + '\\.(js)$': '/node_modules/babel-jest', + '\\.(ts)$': 'ts-jest' + }, coverageDirectory: '/coverage' }; diff --git a/oltypes.d.ts b/oltypes.d.ts new file mode 100644 index 0000000..61f3741 --- /dev/null +++ b/oltypes.d.ts @@ -0,0 +1,7 @@ +declare module 'ol/extent'; +declare module 'ol/geom/Geometry'; +declare module 'ol/style/Style'; +declare module 'ol/geom/Polygon'; +declare module 'ol/geom/LineString'; +declare module 'ol/render/canvas/Immediate'; +declare module 'ol/transform'; diff --git a/package.json b/package.json index bb44950..c0b4d18 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "main": "src/index.js", "scripts": { "release": "np --no-yarn && git push git@github.com:terrestris/legend-util.git master --tags", - "lint": "eslint --ext js src/", + "lint": "tslint --project tsconfig.json --config tslint.json && tsc --noEmit --project tsconfig.json", "test": "jest --maxWorkers=4", "test:watch": "jest --watchAll", "coveralls": "cat coverage/lcov.info | coveralls", "clean:dist": "rimraf ./dist/*", - "build:dist": "npm run clean:dist && webpack", + "build:dist": "tsc -p ./tsconfig.prod.json", "build:dev": "npm run clean:dist && webpack --mode=development", "start:dev": "npm run clean:dist && webpack --mode=development --watch" }, @@ -28,12 +28,15 @@ "peerDependencies": { "d3": "~5" }, + "types": "dist/index.d.ts", "devDependencies": { "@babel/core": "7.2.2", "@babel/plugin-proposal-class-properties": "7.3.0", "@babel/plugin-proposal-function-bind": "7.2.0", "@babel/polyfill": "7.2.5", "@babel/preset-env": "7.3.1", + "@types/d3-selection": "1.3.4", + "@types/jest": "24.0.0", "babel-eslint": "10.0.1", "babel-jest": "24.0.0", "babel-loader": "8.0.5", @@ -43,12 +46,18 @@ "coveralls": "3.0.2", "d3": "5.8.2", "eslint": "5.13.0", + "geostyler": "^3.0.0", "geostyler-openlayers-parser": "1.0.0", + "geostyler-style": "^0.14.3", "jest": "24.0.0", "jsdom": "13.2.0", "np": "4.0.2", "ol": "5.3.0", "rimraf": "2.6.3", + "ts-jest": "23.10.5", + "tslint": "5.12.1", + "typedoc": "0.14.2", + "typescript": "3.3.3", "webpack": "4.29.1", "webpack-cli": "3.2.3" } diff --git a/src/LegendRenderer/LegendRenderer.spec.js b/src/LegendRenderer/LegendRenderer.spec.ts similarity index 68% rename from src/LegendRenderer/LegendRenderer.spec.js rename to src/LegendRenderer/LegendRenderer.spec.ts index 979fd4b..70bd917 100644 --- a/src/LegendRenderer/LegendRenderer.spec.js +++ b/src/LegendRenderer/LegendRenderer.spec.ts @@ -9,15 +9,20 @@ describe('LegendRenderer', () => { }); it('can be constructed', () => { - const renderer = new LegendRenderer(); + const renderer = new LegendRenderer({ + size: [0, 0] + }); expect(renderer).not.toBeUndefined(); }); it('can convert config objects', () => { - const renderer = new LegendRenderer(); + const renderer = new LegendRenderer({ + size: [0, 0] + }); const config = renderer.extractConfigFromStyle({ rules: [{ - name: 'Legend item 1' + name: 'Legend item 1', + symbolizers: [] }], name: 'Legend 1' }); @@ -26,22 +31,29 @@ describe('LegendRenderer', () => { }); it('can convert config objects without style name', () => { - const renderer = new LegendRenderer(); + const renderer = new LegendRenderer({ + size: [0, 0] + }); const config = renderer.extractConfigFromStyle({ rules: [{ - name: 'Legend item 1' - }] + name: 'Legend item 1', + symbolizers: [] + }], + name: '' }); expect(config.title).toBe(undefined); }); it('will not throw when constructing a rule icon', () => { - const renderer = new LegendRenderer(); + const renderer = new LegendRenderer({ + size: [0, 0] + }); expect(() => renderer.getRuleIcon({ symbolizers: [{ kind: 'Mark', wellKnownName: 'Circle' - }] + }], + name: '' })).not.toThrow(); }); diff --git a/src/LegendRenderer/LegendRenderer.ts b/src/LegendRenderer/LegendRenderer.ts new file mode 100644 index 0000000..2498d25 --- /dev/null +++ b/src/LegendRenderer/LegendRenderer.ts @@ -0,0 +1,294 @@ +import { select, Selection, BaseType } from 'd3-selection'; + +import { boundingExtent } from 'ol/extent'; +import OlGeomPoint from 'ol/geom/Point'; +import OlGeomPolygon from 'ol/geom/Polygon'; +import OlGeomLineString from 'ol/geom/LineString'; +import Renderer from 'ol/render/canvas/Immediate'; +import { create as createTransform } from 'ol/transform'; +import { + Style, + Symbolizer, + Rule + } from 'geostyler-style'; + +import OlStyleParser from 'geostyler-openlayers-parser'; + +interface OlStyle {} +interface OlGeometry {} + +interface LegendItemConfiguration { + rule?: Rule; + title: string; +} + +interface LegendConfiguration { + items: LegendItemConfiguration[]; + title: string; +} + +interface LegendsConfiguration { + styles?: Style[]; + configs?: LegendItemConfiguration[]; + size: [number, number]; + maxColumnHeight?: number; + maxColumnWidth?: number; + overflow?: 'auto' | 'group'; +} + +const iconSize = [45, 30]; + +/** + * A class that can be used to render svg legends. + */ +class LegendRenderer { + + config: LegendsConfiguration = null; + + /** + * Constructs a new legend renderer. + * @param {LegendsConfiguration} config the legend configuration + */ + constructor(config: LegendsConfiguration) { + this.config = config; + } + + /** + * Constructs a legend configuration from a geostyler style object. + * @param {Style} style a geostyler style + */ + extractConfigFromStyle(style: Style) { + const config: LegendConfiguration = { + items: [], + title: '' + }; + if (style.name) { + config.title = style.name; + } + style.rules.forEach(rule => { + config.items.push({ + title: rule.name, + rule + }); + }); + return config; + } + + /** + * Renders a single legend item. + * @param {Selection} container the container to append the legend item to + * @param {LegendItemConfiguration} item configuration of the legend item + * @param {[number, number]} position the current position + */ + renderLegendItem( + container: Selection, + item: LegendItemConfiguration, + position: [number, number] + ) { + if (item.rule) { + container = container.append('g') + .attr('class', 'legend-item') + .attr('title', item.title); + const img = this.getRuleIcon(item.rule); + return img.then((uri: string) => { + container.append('rect') + .attr('x', position[0] + 1) + .attr('y', position[1]) + .attr('width', iconSize[0]) + .attr('height', iconSize[1]) + .style('fill-opacity', 0) + .style('stroke', 'black'); + container.append('image') + .attr('x', position[0] + 1) + .attr('y', position[1]) + .attr('width', iconSize[0]) + .attr('height', iconSize[1]) + .attr('href', uri); + container.append('text') + .text(item.title) + .attr('x', position[0] + iconSize[0] + 5) + .attr('y', position[1] + 20); + position[1] += iconSize[1] + 5; + if (this.config.maxColumnHeight && position[1] + iconSize[1] + 5 >= this.config.maxColumnHeight) { + position[1] = 5; + position[0] += this.config.maxColumnWidth; + } + }); + } + return undefined; + } + + /** + * Shortens the labels if they overflow. + * @param {Selection} nodes the legend item group nodes + * @param {number} maxWidth the maximum column width + */ + shortenLabels(nodes: Selection, maxWidth: number) { + nodes.each(function() { + const node = select(this); + const text = node.select('text'); + if (!(node.node() instanceof SVGElement)) { + return; + } + const elem: Element = (node.node()); + let width = elem.getBoundingClientRect().width; + let adapted = false; + while (width > maxWidth) { + let str = text.text(); + str = str.substring(0, str.length - 1); + text.text(str); + width = elem.getBoundingClientRect().width; + adapted = true; + } + if (adapted) { + let str = text.text(); + str = str.substring(0, str.length - 3); + text.text(str + '...'); + } + }); + } + + /** + * Constructs a geometry for rendering a specific symbolizer. + * @param {Symbolizer} symbolizer the symbolizer object + */ + getGeometryForSymbolizer(symbolizer: Symbolizer): OlGeometry { + const kind = symbolizer.kind; + switch (kind) { + case 'Mark': + case 'Icon': + case 'Text': + return new OlGeomPoint([iconSize[0] / 2, iconSize[1] / 2]); + case 'Fill': + return new OlGeomPolygon([[ + [3, 3], [iconSize[0] - 3, 3], [iconSize[0] - 3, iconSize[1] - 3], + [3, iconSize[1] - 3], [3, 3] + ]]); + case 'Line': + return new OlGeomLineString([ + [iconSize[0] / 6, iconSize[1] / 6], + [iconSize[0] / 3, iconSize[1] / 3 * 2], + [iconSize[0] / 2, iconSize[1] / 3], + [iconSize[0] / 6 * 5, iconSize[1] / 6 * 5] + ]); + default: + return new OlGeomPoint([iconSize[0] / 2, iconSize[1] / 2]); + } + } + + /** + * Returns a promise resolving to a data uri with the appropriate rule icon. + * @param {Object} rule the geostyler rule + */ + getRuleIcon(rule: Rule): Promise { + const canvas = document.createElement('canvas'); + canvas.setAttribute('width', `${iconSize[0]}`); + canvas.setAttribute('height', `${iconSize[1]}`); + const extent = boundingExtent([[0, 0], [iconSize[0], iconSize[1]]]); + const pixelRatio = 1; + const context = canvas.getContext('2d'); + const transform = createTransform(); + const renderer = new Renderer(context, pixelRatio, extent, transform, 0); + const geoms: OlGeometry[] = []; + rule.symbolizers.forEach(symbolizer => geoms.push(this.getGeometryForSymbolizer(symbolizer))); + + const styleParser = new OlStyleParser(); + + const style = { + rules: [{ + symbolizers: rule.symbolizers + }] + }; + const promise = new Promise((resolve, reject) => { + styleParser.writeStyle(style) + .then((olStyle: OlStyle) => { + renderer.setStyle(olStyle); + geoms.forEach((geom: OlGeometry) => renderer.drawGeometry(geom)); + resolve(canvas.toDataURL('image/png')); + }) + .catch(() => { + reject(); + }); + }); + return promise; + } + + /** + * Render a single legend. + * @param {LegendConfiguration} config the legend config + * @param {Selection} svg the root node + * @param {[number, number]} position the current position + */ + renderLegend( + config: LegendConfiguration, + svg: Selection, + position: [number, number] + ) { + const container = svg.append('g'); + if (this.config.overflow !== 'auto' && position[0] !== 0) { + const legendHeight = config.items.length * (iconSize[1] + 5) + 20; + if (legendHeight + position[1] > this.config.maxColumnHeight) { + position[0] += this.config.maxColumnWidth; + position[1] = 0; + } + } + if (config.title) { + container.append('text') + .text(config.title) + .attr('text-anchor', 'start') + .attr('dy', position[1] + 10) + .attr('dx', position[0]); + position[1] += 20; + } + + return config.items.reduce((cur, item) => { + return cur.then(() => this.renderLegendItem(svg, item, position)); + }, Promise.resolve()); + } + + /** + * Renders the configured legend. + * @param {HTMLElement} parent a node to append the svg to + */ + render(parent: HTMLElement) { + const { + styles, + configs, + size: [width, height] + } = this.config; + const legends: LegendConfiguration[] = []; + if (styles) { + styles.forEach(style => legends.push(this.extractConfigFromStyle(style))); + } + if (configs) { + legends.unshift.apply(legends, configs); + } + const svg = select(parent) + .append('svg') + .attr('viewBox', `0 0 ${width} ${height}`) + .attr('top', 0) + .attr('left', 0) + .attr('width', width) + .attr('height', height); + + const position: [number, number] = [0, 0]; + + const promise = legends.reduce((cur, legend) => { + return cur.then(() => this.renderLegend(legend, svg, position)); + }, Promise.resolve()); + promise.then(() => { + const nodes = svg.selectAll('g.legend-item'); + this.shortenLabels(nodes, this.config.maxColumnWidth); + if (!this.config.maxColumnHeight) { + svg + .attr('viewBox', `0 0 ${width} ${position[1]}`) + .attr('height', position[1]); + } + }); + + return svg.node(); + } + +} + +export default LegendRenderer; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a8ea9b9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,46 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "baseUrl": "./", + "checkJs": false, + "declaration": true, + "outDir": "dist", + "module": "commonjs", + "target": "es5", + "lib": ["esnext", "dom"], + "sourceMap": true, + "allowJs": false, + "jsx": "react", + "moduleResolution": "node", + "rootDir": "src", + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "strictNullChecks": false, + "suppressImplicitAnyIndexErrors": true, + "noUnusedLocals": true + }, + "include": [ + "src/**/*.ts", + "oltypes.d.ts" + ], + "exclude": [ + "node_modules", + "build", + "browser", + "config", + "dist", + "scripts", + "coverage", + "acceptance-tests", + "webpack", + "jest", + "coverage", + "**.config.js", + "**/*.spec.ts" + ], + "types": [ + "d3-selection" + ] +} diff --git a/tsconfig.prod.json b/tsconfig.prod.json new file mode 100644 index 0000000..21e830a --- /dev/null +++ b/tsconfig.prod.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "sourceMap": false, + "esModuleInterop": true + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..295e63f --- /dev/null +++ b/tslint.json @@ -0,0 +1,86 @@ +{ + "defaultSeverity": "warning", + "rules": { + "align": [ + true, + "statements" + ], + "ban": false, + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "curly": true, + "eofline": true, + "forin": true, + "indent": [ true, "spaces", 2 ], + "interface-name": [true, "never-prefix"], + "jsdoc-format": true, + "label-position": true, + "max-line-length": [ true, 120 ], + "member-ordering": [ + true, + { + "order": [ + "public-instance-method" + ] + } + ], + "no-any": false, + "no-arg": true, + "no-bitwise": true, + "no-console": false, + "no-consecutive-blank-lines": true, + "no-construct": true, + "no-debugger": false, + "no-duplicate-variable": true, + "no-empty": true, + "no-eval": true, + "no-shadowed-variable": true, + "no-string-literal": true, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unused-expression": true, + "no-use-before-declare": true, + "one-line": [ + true, + "check-catch", + "check-else", + "check-open-brace", + "check-whitespace" + ], + "quotemark": [true, "single", "jsx-double"], + "radix": true, + "semicolon": [true, "always"], + "switch-default": true, + "trailing-comma": [false], + "triple-equals": [ true, "allow-null-check" ], + "typedef": [ + true, + "parameter", + "property-declaration" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore", "allow-pascal-case"], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-module", + "check-operator", + "check-separator", + "check-type", + "check-typecast" + ] + } +} diff --git a/webpack.config.js b/webpack.config.js index 039037d..230a587 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,7 +1,7 @@ module.exports = { module: { rules: [{ - test: /\.jsx?$/, + test: /\.[jt]sx?$/, exclude: /node_modules\/(?!@terrestris)/, use: 'babel-loader' }, {