From 7e85798e511417d1d74b8895e78a2ba9bacda554 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Sun, 3 Jul 2022 15:16:00 +0200 Subject: [PATCH 1/9] Add separate output logic with SVG implementation This implements atomic operations like adding text or images, as well as generating the final image. --- src/LegendRenderer/AbstractOutput.ts | 21 +++++ src/LegendRenderer/SvgOutput.ts | 123 +++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/LegendRenderer/AbstractOutput.ts create mode 100644 src/LegendRenderer/SvgOutput.ts diff --git a/src/LegendRenderer/AbstractOutput.ts b/src/LegendRenderer/AbstractOutput.ts new file mode 100644 index 0000000..dad34c8 --- /dev/null +++ b/src/LegendRenderer/AbstractOutput.ts @@ -0,0 +1,21 @@ +abstract class AbstractOutput { + protected constructor( + protected size: [number, number], + protected maxColumnWidth: number | null, + protected maxColumnHeight: number | null + ) {} + abstract useContainer(title: string): void; + abstract useRoot(): void; + abstract addTitle(text: string, x: number|string, y: number|string): void; + abstract addLabel(text: string, x: number|string, y: number|string): void; + abstract addImage( + dataUrl: string, + imgWidth: number, + imgHeight: number, + x: number|string, + y: number|string, + drawRect: boolean, + ): void; + abstract generate(finalHeight: number): Element; +} +export default AbstractOutput; diff --git a/src/LegendRenderer/SvgOutput.ts b/src/LegendRenderer/SvgOutput.ts new file mode 100644 index 0000000..153e32b --- /dev/null +++ b/src/LegendRenderer/SvgOutput.ts @@ -0,0 +1,123 @@ +import {BaseType, select, Selection} from 'd3-selection'; +import AbstractOutput from './AbstractOutput'; + +export default class SvgOutput extends AbstractOutput { + root: Selection = null; + currentContainer: Selection = null; + + constructor( + target: HTMLElement, + size: [number, number], + maxColumnWidth: number | null, + maxColumnHeight: number | null + ) { + super(size, maxColumnWidth, maxColumnHeight); + + const svgClass = 'geostyler-legend-renderer'; + const parent = select(target); + parent.select(`.${svgClass}`).remove(); + + this.root = parent + .append('svg') + .attr('class', svgClass) + .attr('viewBox', `0 0 ${size[0]} ${size[1]}`) + .attr('top', 0) + .attr('left', 0) + .attr('width', size[0]) + .attr('height', size[1]); + this.currentContainer = this.root; + } + + useContainer(title: string) { + this.currentContainer = this.root.append('g') + .attr('class', 'legend-item') + .attr('title', title); + }; + + useRoot() { + this.currentContainer = this.root; + } + + addTitle(text: string, x: number | string, y: number | string) { + this.currentContainer.append('g').append('text') + .text(text) + .attr('class', 'legend-title') + .attr('text-anchor', 'start') + .attr('dx', x) + .attr('dy', y); + }; + + addLabel(text: string, x: number | string, y: number | string) { + this.currentContainer.append('text') + .text(text) + .attr('x', x) + .attr('y', y); + }; + + addImage( + dataUrl: string, + imgWidth: number, + imgHeight: number, + x: number|string, + y: number|string, + drawRect: boolean, + ) { + if (drawRect) { + this.currentContainer.append('rect') + .attr('x', x) + .attr('y', y) + .attr('width', imgWidth) + .attr('height', imgHeight) + .style('fill-opacity', 0) + .style('stroke', 'black'); + } + this.currentContainer.append('svg:image') + .attr('x', x) + .attr('y', y) + .attr('width', imgWidth) + .attr('height', imgHeight) + .attr('href', dataUrl); + this.root.attr('xmlns', 'http://www.w3.org/2000/svg'); + }; + + /** + * Shortens the labels if they overflow. + * @param {Selection} nodes the legend item group nodes + * @param {number} maxWidth the maximum column width + */ + private 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 + '...'); + } + }); + } + + generate(finalHeight: number) { + const nodes = this.root.selectAll('g.legend-item'); + this.shortenLabels(nodes, this.maxColumnWidth); + if (!this.maxColumnHeight) { + this.root + .attr('viewBox', `0 0 ${this.size[0]} ${finalHeight}`) + .attr('height', finalHeight); + } + return this.root.node() as SVGElement; + } +} From 5cc2078ef22ab0864e3a3409b196b86c98b63a24 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Sun, 17 Jul 2022 13:44:12 +0200 Subject: [PATCH 2/9] Use SvgOutput in the LegendRenderer This means that LegendRenderer does not use SVG or D3 at all and only rely on the output implementation. This change should not change the output or behaviour of LegendRenderer in any way, except for one this: the 'xmlns' attribute is now set every time an image is added to the svg legend, and not only with remote legends. --- src/LegendRenderer/LegendRenderer.ts | 139 ++++++--------------------- 1 file changed, 28 insertions(+), 111 deletions(-) diff --git a/src/LegendRenderer/LegendRenderer.ts b/src/LegendRenderer/LegendRenderer.ts index 6111cfb..36ef2c3 100644 --- a/src/LegendRenderer/LegendRenderer.ts +++ b/src/LegendRenderer/LegendRenderer.ts @@ -1,5 +1,3 @@ -import { select, Selection, BaseType } from 'd3-selection'; - import { boundingExtent } from 'ol/extent'; import OlGeometry from 'ol/geom/Geometry'; import OlGeomPoint from 'ol/geom/Point'; @@ -15,6 +13,8 @@ import { } from 'geostyler-style'; import OlStyleParser from 'geostyler-openlayers-parser'; import OlFeature from 'ol/Feature'; +import SvgOutput from './SvgOutput'; +import AbstractOutput from './AbstractOutput'; interface LegendItemConfiguration { rule?: Rule; @@ -42,10 +42,10 @@ interface LegendsConfiguration { hideRect?: boolean; } -const iconSize = [45, 30]; +const iconSize: [number, number] = [45, 30]; /** - * A class that can be used to render svg legends. + * A class that can be used to render legends as images. */ class LegendRenderer { @@ -82,12 +82,12 @@ class LegendRenderer { /** * Renders a single legend item. - * @param {Selection} container the container to append the legend item to + * @param {AbstractOutput} output * @param {LegendItemConfiguration} item configuration of the legend item * @param {[number, number]} position the current position */ renderLegendItem( - container: Selection, + output: AbstractOutput, item: LegendItemConfiguration, position: [number, number] ) { @@ -99,30 +99,11 @@ class LegendRenderer { } = this.config; if (item.rule) { - container = container.append('g') - .attr('class', 'legend-item') - .attr('title', item.title); + output.useContainer(item.title); return this.getRuleIcon(item.rule) .then((uri) => { - if (!hideRect) { - 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); + output.addImage(uri, ...iconSize, position[0] + 1, position[1], !hideRect); + output.addLabel(item.title, position[0] + iconSize[0] + 5, position[1] + 20); position[1] += iconSize[1] + 5; if (maxColumnHeight && position[1] + iconSize[1] + 5 >= maxColumnHeight) { position[1] = 5; @@ -136,36 +117,6 @@ class LegendRenderer { 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 @@ -250,15 +201,15 @@ class LegendRenderer { /** * Render a single legend. * @param {LegendConfiguration} config the legend config - * @param {Selection} svg the root node + * @param {AbstractOutput} output * @param {[number, number]} position the current position */ renderLegend( config: LegendConfiguration, - svg: Selection, + output: AbstractOutput, position: [number, number] ) { - const container = svg.append('g'); + output.useRoot(); if (this.config.overflow !== 'auto' && position[0] !== 0) { const legendHeight = config.items.length * (iconSize[1] + 5) + 20; if (legendHeight + position[1] > this.config.maxColumnHeight) { @@ -267,29 +218,24 @@ class LegendRenderer { } } if (config.title) { - container.append('text') - .text(config.title) - .attr('class', 'legend-title') - .attr('text-anchor', 'start') - .attr('dx', position[0]) - .attr('dy', position[1] === 0 ? '1em': position[1] + 15); + output.addTitle(config.title, position[0], position[1] === 0 ? '1em': position[1] + 15); position[1] += 20; } return config.items.reduce((cur, item) => { - return cur.then(() => this.renderLegendItem(svg, item, position)); + return cur.then(() => this.renderLegendItem(output, item, position)); }, Promise.resolve()); } /** * Render all images given by URL and append them to the legend * @param {RemoteLegend[]} remoteLegends the array of remote legend objects - * @param {Selection} svg the root node + * @param {AbstractOutput} output * @param {[number, number]} position the current position */ async renderImages( remoteLegends: RemoteLegend[], - svg: Selection, + output: AbstractOutput, position: [number, number] ) { const legendSpacing = 20; @@ -327,30 +273,19 @@ class LegendRenderer { position[1] = 0; } if (legendTitle) { - const container = svg.append('g'); + output.useRoot(); position[1] += legendSpacing; - container.append('text') - .text(legendTitle) - .attr('class', 'legend-title') - .attr('text-anchor', 'start') - .attr('dx', position[0]) - .attr('dy', position[1]); + output.addTitle(legendTitle, ...position); position[1] += titleSpacing; } - svg.append('svg:image') - .attr('x', position[0]) - .attr('y', position[1]) - .attr('width', img.width) - .attr('height', img.height) - .attr('href', base64.toString()); + output.addImage(base64.toString(), img.width, img.height,...position, false); position[1] += img.height; } catch (err) { console.error('Error on fetching legend: ', err); continue; } - }; - svg.attr('xmlns', 'http://www.w3.org/2000/svg'); + } } /** @@ -363,7 +298,9 @@ class LegendRenderer { styles, configs, size: [width, height], - remoteLegends + remoteLegends, + maxColumnWidth, + maxColumnHeight, } = this.config; const legends: LegendConfiguration[] = []; if (styles) { @@ -372,35 +309,15 @@ class LegendRenderer { if (configs) { legends.unshift.apply(legends, configs); } - - const svgClass = 'geostyler-legend-renderer'; - const parent = select(target); - parent.select(`.${svgClass}`).remove(); - - const svg = parent - .append('svg') - .attr('class', svgClass) - .attr('viewBox', `0 0 ${width} ${height}`) - .attr('top', 0) - .attr('left', 0) - .attr('width', width) - .attr('height', height); - + const svgOutput = new SvgOutput(target, [width, height], maxColumnWidth, maxColumnHeight); const position: [number, number] = [0, 0]; for (let i = 0; i < legends.length; i++) { - await this.renderLegend(legends[i], svg, position); - }; - if (remoteLegends) { - await this.renderImages(remoteLegends, svg, position); + await this.renderLegend(legends[i], svgOutput, position); } - 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]); + if (remoteLegends) { + await this.renderImages(remoteLegends, svgOutput, position); } - return svg; + return svgOutput.generate(position[1]); } } export default LegendRenderer; From b8f49d162552dd8cbba1c5a5658025417cb782f6 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Sun, 17 Jul 2022 15:02:57 +0200 Subject: [PATCH 3/9] Add PngOutput class with currently no logic, adjusted LegendRenderer LegendRenderer can now give an image without a container, and the output type can be changed (SVG by default). --- src/LegendRenderer/LegendRenderer.ts | 28 +++++++++------ src/LegendRenderer/PngOutput.ts | 52 ++++++++++++++++++++++++++++ src/LegendRenderer/SvgOutput.ts | 20 ++++++----- 3 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 src/LegendRenderer/PngOutput.ts diff --git a/src/LegendRenderer/LegendRenderer.ts b/src/LegendRenderer/LegendRenderer.ts index 36ef2c3..d1c32bd 100644 --- a/src/LegendRenderer/LegendRenderer.ts +++ b/src/LegendRenderer/LegendRenderer.ts @@ -15,6 +15,7 @@ import OlStyleParser from 'geostyler-openlayers-parser'; import OlFeature from 'ol/Feature'; import SvgOutput from './SvgOutput'; import AbstractOutput from './AbstractOutput'; +import PngOutput from './PngOutput' interface LegendItemConfiguration { rule?: Rule; @@ -288,12 +289,7 @@ class LegendRenderer { } } - /** - * Renders the configured legend. - * @param {HTMLElement} target a node to append the svg to - * @return {SVGSVGElement} The final SVG legend - */ - async render(target: HTMLElement) { + async renderAsImage(format?: 'svg' | 'png', target?: HTMLElement): Promise { const { styles, configs, @@ -309,15 +305,27 @@ class LegendRenderer { if (configs) { legends.unshift.apply(legends, configs); } - const svgOutput = new SvgOutput(target, [width, height], maxColumnWidth, maxColumnHeight); + const outputClass = format === 'svg' ? SvgOutput : PngOutput; + const output = new outputClass([width, height], maxColumnWidth, maxColumnHeight, target); const position: [number, number] = [0, 0]; for (let i = 0; i < legends.length; i++) { - await this.renderLegend(legends[i], svgOutput, position); + await this.renderLegend(legends[i], output, position); } if (remoteLegends) { - await this.renderImages(remoteLegends, svgOutput, position); + await this.renderImages(remoteLegends, output, position); } - return svgOutput.generate(position[1]); + return output.generate(position[1]); + } + + /** + * Renders the configured legend as an SVG image in the given target container. All pre-existing legends + * will be removed. + * @param {HTMLElement} target a node to append the svg to + * @param format + * @return {SVGSVGElement} The final SVG legend + */ + async render(target: HTMLElement, format: 'svg' | 'png' = 'svg') { + await this.renderAsImage(format, target); } } export default LegendRenderer; diff --git a/src/LegendRenderer/PngOutput.ts b/src/LegendRenderer/PngOutput.ts new file mode 100644 index 0000000..ef86dd9 --- /dev/null +++ b/src/LegendRenderer/PngOutput.ts @@ -0,0 +1,52 @@ +import AbstractOutput from './AbstractOutput'; + +const ROOT_CLASS = 'geostyler-legend-renderer'; + +export default class PngOutput extends AbstractOutput { + canvas: HTMLCanvasElement; + + constructor( + size: [number, number], + maxColumnWidth: number | null, + maxColumnHeight: number | null, + private target?: HTMLElement, + ) { + super(size, maxColumnWidth, maxColumnHeight); + this.canvas = document.createElement('canvas'); + this.canvas.className = ROOT_CLASS; + this.canvas.width = size[0]; + this.canvas.height = size[1]; + if (this.target) { + target.querySelectorAll(`.${ROOT_CLASS}`).forEach(e => e.remove()); + target.append(this.canvas); + } + } + + useContainer(title: string) { + } + + useRoot() { + } + + addTitle(text: string, x: number | string, y: number | string) { + } + + addLabel(text: string, x: number | string, y: number | string) { + } + + addImage( + dataUrl: string, + imgWidth: number, + imgHeight: number, + x: number|string, + y: number|string, + drawRect: boolean, + ) { + } + + generate(finalHeight: number) { + const ctx = this.canvas.getContext('2d'); + ctx.fillText(`Hello world! size = ${this.size[0]}x${this.size[1]}`, 20, 20); + return this.canvas; + } +} diff --git a/src/LegendRenderer/SvgOutput.ts b/src/LegendRenderer/SvgOutput.ts index 153e32b..50f1f50 100644 --- a/src/LegendRenderer/SvgOutput.ts +++ b/src/LegendRenderer/SvgOutput.ts @@ -1,31 +1,35 @@ import {BaseType, select, Selection} from 'd3-selection'; import AbstractOutput from './AbstractOutput'; +const ROOT_CLASS = 'geostyler-legend-renderer'; + export default class SvgOutput extends AbstractOutput { root: Selection = null; currentContainer: Selection = null; constructor( - target: HTMLElement, size: [number, number], maxColumnWidth: number | null, - maxColumnHeight: number | null + maxColumnHeight: number | null, + target?: HTMLElement, ) { super(size, maxColumnWidth, maxColumnHeight); - const svgClass = 'geostyler-legend-renderer'; - const parent = select(target); - parent.select(`.${svgClass}`).remove(); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') as SVGSVGElement; - this.root = parent - .append('svg') - .attr('class', svgClass) + this.root = select(svg) + .attr('class', ROOT_CLASS) .attr('viewBox', `0 0 ${size[0]} ${size[1]}`) .attr('top', 0) .attr('left', 0) .attr('width', size[0]) .attr('height', size[1]); this.currentContainer = this.root; + + if (target) { + select(target).select(`.${ROOT_CLASS}`).remove(); + target.append(this.root.node()); + } } useContainer(title: string) { From 9777327449f285f7f11dd312187c13c6b11738c2 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Sun, 17 Jul 2022 18:01:01 +0200 Subject: [PATCH 4/9] Actually implement png output logic Containers are ignored in PNG, and a custom logic is added to extend the canvas when an image goes outside of the frame. --- src/LegendRenderer/PngOutput.ts | 55 +++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/LegendRenderer/PngOutput.ts b/src/LegendRenderer/PngOutput.ts index ef86dd9..fcc4c63 100644 --- a/src/LegendRenderer/PngOutput.ts +++ b/src/LegendRenderer/PngOutput.ts @@ -2,8 +2,21 @@ import AbstractOutput from './AbstractOutput'; const ROOT_CLASS = 'geostyler-legend-renderer'; +function cssDimensionToPx(dimension: string | number): number { + if (typeof dimension === 'number') { + return dimension; + } + const div = document.createElement('div'); + document.body.append(div); + div.style.height = dimension; + const height = parseFloat(getComputedStyle(div).height.replace(/px$/, '')); + div.remove(); + return height; +} + export default class PngOutput extends AbstractOutput { canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; constructor( size: [number, number], @@ -12,26 +25,42 @@ export default class PngOutput extends AbstractOutput { private target?: HTMLElement, ) { super(size, maxColumnWidth, maxColumnHeight); + this.createCanvas(...size); + } + + private createCanvas(width: number, height: number) { this.canvas = document.createElement('canvas'); this.canvas.className = ROOT_CLASS; - this.canvas.width = size[0]; - this.canvas.height = size[1]; + this.canvas.width = width; + this.canvas.height = height; + this.context = this.canvas.getContext('2d'); + this.context.font = '14px sans-serif'; + if (this.target) { - target.querySelectorAll(`.${ROOT_CLASS}`).forEach(e => e.remove()); - target.append(this.canvas); + this.target.querySelectorAll(`.${ROOT_CLASS}`).forEach(e => e.remove()); + this.target.append(this.canvas); } } - useContainer(title: string) { + private expandHeight(newHeight: number) { + if (this.canvas.height >= newHeight) { + return; + } + const oldCanvas = this.canvas; + this.createCanvas(this.canvas.width, newHeight); + this.context.drawImage(oldCanvas, 0, 0); } - useRoot() { - } + useContainer(title: string) {} + + useRoot() {} addTitle(text: string, x: number | string, y: number | string) { + this.context.fillText(text, cssDimensionToPx(x), cssDimensionToPx(y)); } addLabel(text: string, x: number | string, y: number | string) { + this.context.fillText(text, cssDimensionToPx(x), cssDimensionToPx(y)); } addImage( @@ -42,11 +71,19 @@ export default class PngOutput extends AbstractOutput { y: number|string, drawRect: boolean, ) { + const xPx = cssDimensionToPx(x); + const yPx = cssDimensionToPx(y); + this.expandHeight(yPx + imgHeight); + const image = new Image(); + image.src = dataUrl; + this.context.drawImage(image, xPx, yPx, imgWidth, imgHeight); + if (drawRect) { + this.context.strokeStyle = '1px solid black'; + this.context.strokeRect(xPx, yPx, imgWidth, imgHeight); + } } generate(finalHeight: number) { - const ctx = this.canvas.getContext('2d'); - ctx.fillText(`Hello world! size = ${this.size[0]}x${this.size[1]}`, 20, 20); return this.canvas; } } From 034528d2f17cd19223de2536b3c26c2cbe99a268 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Sun, 11 Sep 2022 11:57:06 +0200 Subject: [PATCH 5/9] Add tests for SvgOutput --- src/LegendRenderer/LegendRenderer.spec.ts | 1 - src/LegendRenderer/SvgOutput.spec.ts | 142 ++++++++++++++++++++++ src/LegendRenderer/SvgOutput.ts | 4 +- src/fixtures/outputs.ts | 51 ++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 src/LegendRenderer/SvgOutput.spec.ts create mode 100644 src/fixtures/outputs.ts diff --git a/src/LegendRenderer/LegendRenderer.spec.ts b/src/LegendRenderer/LegendRenderer.spec.ts index 300f68f..dbb1ef6 100644 --- a/src/LegendRenderer/LegendRenderer.spec.ts +++ b/src/LegendRenderer/LegendRenderer.spec.ts @@ -1,7 +1,6 @@ /* eslint-env jest */ import LegendRenderer from './LegendRenderer'; -import { select } from 'd3-selection'; describe('LegendRenderer', () => { diff --git a/src/LegendRenderer/SvgOutput.spec.ts b/src/LegendRenderer/SvgOutput.spec.ts new file mode 100644 index 0000000..41bba62 --- /dev/null +++ b/src/LegendRenderer/SvgOutput.spec.ts @@ -0,0 +1,142 @@ +/* eslint-env jest */ + +import SvgOutput from './SvgOutput'; +import { + makeSampleOutput, + SAMPLE_SVG, + SAMPLE_SVG_COLUMN_CONSTRAINTS, + SAMPLE_OUTPUT_FINAL_HEIGHT +} from '../fixtures/outputs'; + +// mock getBoundingClientRect on created DOM elements +// (by default jsdom always return 0 so there's no way to test label shortening) +(document as any).originalCreateElementNS = document.createElementNS; +document.createElementNS = function(namespace: string, eltName: string) { + const el = (document as any).originalCreateElementNS(namespace, eltName); + el.getBoundingClientRect = function(): DOMRect { + const charCount = this.textContent.length; + return { + height: 10, + width: charCount * 6, + x: 0, + y: 0, + left: 0, + top: 0, + bottom: 0, + right: 0, + toJSON: () => '' + }; + }; + return el; +}; + +describe('SvgOutput', () => { + let output: SvgOutput; + + it('is defined', () => { + expect(SvgOutput).not.toBeUndefined(); + }); + + describe('individual actions', () => { + beforeEach(() => { + output = new SvgOutput([500, 700], undefined, undefined); + }); + + describe('#useContainer', () => { + it('inserts elements in a container', () => { + output.useContainer('My Container'); + output.addImage('bla', 100, 50, 200, 250, false); + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).innerHTML).toContain( + ' { + it('closes container and inserts elements at the root', () => { + output.useContainer('My Container'); + output.useRoot(); + output.addImage('bla', 100, 50, 200, 250, false); + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).innerHTML).toContain( + ' { + it('inserts a title', () => { + output.addTitle('My Title', 100, 150); + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).innerHTML).toEqual( + 'My Title' + ); + }); + }); + describe('#addLabel', () => { + it('inserts a label', () => { + output.addLabel('My Label', 100, 150); + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).innerHTML).toEqual( + 'My Label' + ); + }); + }); + describe('#addImage', () => { + it('inserts an image (no frame)', () => { + output.addImage('bla', 100, 50, 200, 250, false); + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).innerHTML).toEqual( + '' + ); + }); + it('inserts an image (with frame)', () => { + output.addImage('bla', 100, 50, 200, 250, true); + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).innerHTML).toEqual( + '' + + '' + ); + }); + }); + }); + + describe('without column constraints', () => { + beforeEach(() => { + output = new SvgOutput([500, 700], undefined, undefined); + makeSampleOutput(output); + }); + it('generates the right output', () => { + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).outerHTML).toEqual(SAMPLE_SVG); + }); + }); + + describe('with column constraints', () => { + beforeEach(() => { + output = new SvgOutput([500, 700], 50, 200); + makeSampleOutput(output); + }); + it('generates the right output', () => { + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).outerHTML).toEqual(SAMPLE_SVG_COLUMN_CONSTRAINTS); + }); + }); + + describe('with a height too low', () => { + beforeEach(() => { + output = new SvgOutput([500, 200], 50, 200); + makeSampleOutput(output); + }); + it('sets the height on the final canvas', () => { + expect(output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT).outerHTML).toEqual(SAMPLE_SVG_COLUMN_CONSTRAINTS + .replace('viewBox="0 0 500 700" top="0" left="0" width="500" height="700"', + 'viewBox="0 0 500 200" top="0" left="0" width="500" height="200"')); + }); + }); + + describe('when a target is given', () => { + let root: HTMLDivElement; + beforeEach(() => { + root = document.createElement('div'); + output = new SvgOutput([500, 700], 250, 500, root); + }); + it('appends the output to the target element', () => { + output.generate(123); + expect(root.children.item(0).outerHTML).toEqual( + '' + ); + }); + }); +}); diff --git a/src/LegendRenderer/SvgOutput.ts b/src/LegendRenderer/SvgOutput.ts index 50f1f50..a1e0351 100644 --- a/src/LegendRenderer/SvgOutput.ts +++ b/src/LegendRenderer/SvgOutput.ts @@ -93,10 +93,10 @@ export default class SvgOutput extends AbstractOutput { nodes.each(function() { const node = select(this); const text = node.select('text'); - if (!(node.node() instanceof SVGElement)) { + if (!(node.node() instanceof SVGElement) || !text.size()) { return; } - const elem: Element = (node.node()); + const elem: Element = (text.node()); let width = elem.getBoundingClientRect().width; let adapted = false; while (width > maxWidth) { diff --git a/src/fixtures/outputs.ts b/src/fixtures/outputs.ts new file mode 100644 index 0000000..205bba8 --- /dev/null +++ b/src/fixtures/outputs.ts @@ -0,0 +1,51 @@ +import AbstractOutput from '../LegendRenderer/AbstractOutput'; + +export function makeSampleOutput(output: AbstractOutput) { + output.useContainer('My Container'); + output.addTitle('Inside a container', 180, 180); + output.addLabel('An image in a container', 200, 220); + output.addImage('https://my-domain/image.png', 100, 50, 200, 250, false); + output.useRoot(); + output.addTitle('Outside a container', 180, 480); + output.addLabel('An image', 200, 520); + output.addImage('https://my-domain/image2.png', 100, 50, 200, 550, true); +} + +export const SAMPLE_OUTPUT_FINAL_HEIGHT = 600; + +export const SAMPLE_SVG = + '' + + '' + + '' + + 'Inside a container' + + '' + + 'An image in a container' + + '' + + '' + + '' + + 'Outside a container' + + '' + + 'An image' + + '' + + '' + + ''; + +// max column width: 50, max column height: 200 +export const SAMPLE_SVG_COLUMN_CONSTRAINTS = + '' + + '' + + '' + + 'Insid...' + + '' + + 'An image in a container' + + '' + + '' + + '' + + 'Outside a container' + + '' + + 'An image' + + '' + + '' + + ''; From 80ec67b96833b445ad9a40ad5345561b4839ef7b Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Sun, 11 Sep 2022 17:08:21 +0200 Subject: [PATCH 6/9] Add tests for PngOutput --- src/LegendRenderer/LegendRenderer.ts | 2 +- src/LegendRenderer/PngOutput.spec.ts | 128 +++++++++++++++ src/fixtures/outputs.ts | 226 +++++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 src/LegendRenderer/PngOutput.spec.ts diff --git a/src/LegendRenderer/LegendRenderer.ts b/src/LegendRenderer/LegendRenderer.ts index d1c32bd..f783d86 100644 --- a/src/LegendRenderer/LegendRenderer.ts +++ b/src/LegendRenderer/LegendRenderer.ts @@ -15,7 +15,7 @@ import OlStyleParser from 'geostyler-openlayers-parser'; import OlFeature from 'ol/Feature'; import SvgOutput from './SvgOutput'; import AbstractOutput from './AbstractOutput'; -import PngOutput from './PngOutput' +import PngOutput from './PngOutput'; interface LegendItemConfiguration { rule?: Rule; diff --git a/src/LegendRenderer/PngOutput.spec.ts b/src/LegendRenderer/PngOutput.spec.ts new file mode 100644 index 0000000..cbd50fc --- /dev/null +++ b/src/LegendRenderer/PngOutput.spec.ts @@ -0,0 +1,128 @@ +/* eslint-env jest */ + +import PngOutput from './PngOutput'; +import { + makeSampleOutput, + SAMPLE_OUTPUT_FINAL_HEIGHT, + SAMPLE_PNG_EVENTS, + SAMPLE_PNG_EVENTS_HEIGHT_TOO_LOW +} from '../fixtures/outputs'; + +function instrumentContext(context: CanvasRenderingContext2D) { + context.drawImage = jest.fn(context.drawImage) as any; + context.fillText = jest.fn(context.fillText) as any; + context.strokeRect = jest.fn(context.strokeRect) as any; +} + +function getContextEvents(context: CanvasRenderingContext2D) { + // eslint-disable-next-line no-underscore-dangle + return (context as any).__getEvents(); +} + +describe('PngOutput', () => { + let output: PngOutput; + + it('is defined', () => { + expect(PngOutput).not.toBeUndefined(); + }); + + describe('individual actions', () => { + beforeEach(() => { + output = new PngOutput([500, 700], undefined, undefined); + instrumentContext(output.context); + }); + + describe('#useContainer', () => { + it('does nothing', () => { + output.useContainer('My Container'); + // succeed + }); + }); + describe('#useRoot', () => { + it('does nothing', () => { + output.useContainer('My Container'); + output.useRoot(); + // succeed + }); + }); + describe('#addTitle', () => { + it('inserts a title', () => { + output.addTitle('My Title', 100, 150); + expect(output.context.fillText).toHaveBeenCalledWith('My Title', 100, 150); + expect(output.context.fillStyle).toEqual('#000000'); + }); + }); + describe('#addLabel', () => { + it('inserts a label', () => { + output.addLabel('My Label', 100, 150); + expect(output.context.fillText).toHaveBeenCalledWith('My Label', 100, 150); + expect(output.context.fillStyle).toEqual('#000000'); + }); + }); + describe('#addImage', () => { + it('inserts an image (no frame)', () => { + output.addImage('bla', 100, 50, 200, 250, false); + const calledImg = (output.context.drawImage as any).mock.calls[0][0]; + expect(output.context.drawImage).toHaveBeenCalledWith(expect.any(Image), 200, 250, 100, 50); + expect(calledImg.src).toBe('http://localhost/bla'); + expect(output.context.strokeRect).not.toHaveBeenCalled(); + expect(output.context.strokeStyle).toEqual('#000000'); + }); + it('inserts an image (with frame)', () => { + output.addImage('bla', 100, 50, 200, 250, true); + const calledImg = (output.context.drawImage as any).mock.calls[0][0]; + expect(output.context.drawImage).toHaveBeenCalledWith(expect.any(Image), 200, 250, 100, 50); + expect(calledImg.src).toBe('http://localhost/bla'); + expect(output.context.strokeRect).toHaveBeenCalledWith(200, 250, 100, 50); + expect(output.context.strokeStyle).toEqual('#000000'); + }); + }); + }); + + describe('without column constraints', () => { + beforeEach(() => { + output = new PngOutput([500, 700], undefined, undefined); + makeSampleOutput(output); + }); + it('generates the right output', () => { + const canvas = output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT); + expect(getContextEvents(canvas.getContext('2d'))).toEqual(SAMPLE_PNG_EVENTS); + }); + }); + + describe('with column constraints', () => { + beforeEach(() => { + output = new PngOutput([500, 700], 50, 200); + makeSampleOutput(output); + }); + it('generates the same output as without constraints', () => { + const canvas = output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT); + expect(getContextEvents(canvas.getContext('2d'))).toEqual(SAMPLE_PNG_EVENTS); + }); + }); + + describe('with a height too low', () => { + beforeEach(() => { + output = new PngOutput([500, 200], 50, 200); + makeSampleOutput(output); + }); + it('resizes the final canvas', () => { + const canvas = output.generate(SAMPLE_OUTPUT_FINAL_HEIGHT); + expect(getContextEvents(canvas.getContext('2d'))).toEqual(SAMPLE_PNG_EVENTS_HEIGHT_TOO_LOW); + }); + }); + + describe('when a target is given', () => { + let root: HTMLDivElement; + beforeEach(() => { + root = document.createElement('div'); + output = new PngOutput([500, 700], 250, 500, root); + }); + it('appends the output to the target element', () => { + output.generate(123); + expect(root.children.item(0).outerHTML).toEqual( + '' + ); + }); + }); +}); diff --git a/src/fixtures/outputs.ts b/src/fixtures/outputs.ts index 205bba8..5c681fe 100644 --- a/src/fixtures/outputs.ts +++ b/src/fixtures/outputs.ts @@ -49,3 +49,229 @@ export const SAMPLE_SVG_COLUMN_CONSTRAINTS = '' + '' + ''; + +// as reported by jest-canvas-mock +export const SAMPLE_PNG_EVENTS = [ + { + 'props': { + 'value': '14px sans-serif' + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'font' + }, + { + 'props': { + 'maxWidth': null as number, + 'text': 'Inside a container', + 'x': 180, + 'y': 180 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'fillText' + }, + { + 'props': { + 'maxWidth': null, + 'text': 'An image in a container', + 'x': 200, + 'y': 220 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'fillText' + }, + { + 'props': { + 'dHeight': 0, + 'dWidth': 0, + 'dx': 200, + 'dy': 250, + 'img': expect.any(Image), + 'sHeight': 0, + 'sWidth': 0, + 'sx': 0, + 'sy': 0 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'drawImage' + }, + { + 'props': { + 'maxWidth': null, + 'text': 'Outside a container', + 'x': 180, + 'y': 480 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'fillText' + }, + { + 'props': { + 'maxWidth': null, + 'text': 'An image', + 'x': 200, + 'y': 520 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'fillText' + }, + { + 'props': { + 'dHeight': 0, + 'dWidth': 0, + 'dx': 200, + 'dy': 550, + 'img': expect.any(Image), + 'sHeight': 0, + 'sWidth': 0, + 'sx': 0, + 'sy': 0 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'drawImage' + }, + { + 'props': { + 'height': 50, + 'width': 100, + 'x': 200, + 'y': 550 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'strokeRect' + } +]; + +// events when starting with a height of 200 +export const SAMPLE_PNG_EVENTS_HEIGHT_TOO_LOW = [ + { + 'props': { + 'value': '14px sans-serif' + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'font' + }, + { + 'props': { + 'dHeight': 300, + 'dWidth': 500, + 'dx': 0, + 'dy': 0, + 'img': expect.any(HTMLCanvasElement), + 'sHeight': 300, + 'sWidth': 500, + 'sx': 0, + 'sy': 0 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'drawImage' + }, + { + 'props': { + 'dHeight': 0, + 'dWidth': 0, + 'dx': 200, + 'dy': 550, + 'img': expect.any(Image), + 'sHeight': 0, + 'sWidth': 0, + 'sx': 0, + 'sy': 0 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'drawImage' + }, + { + 'props': { + 'height': 50, + 'width': 100, + 'x': 200, + 'y': 550 + }, + 'transform': [ + 1, + 0, + 0, + 1, + 0, + 0 + ], + 'type': 'strokeRect' + } +]; From eb527210b451123048413ad67586abd474fc9bb4 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Mon, 12 Sep 2022 17:58:58 +0200 Subject: [PATCH 7/9] Fix tests for LegendRenderer Remove dependencies to d3, check for output calls instead --- src/LegendRenderer/LegendRenderer.spec.ts | 33 ++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/LegendRenderer/LegendRenderer.spec.ts b/src/LegendRenderer/LegendRenderer.spec.ts index dbb1ef6..107005d 100644 --- a/src/LegendRenderer/LegendRenderer.spec.ts +++ b/src/LegendRenderer/LegendRenderer.spec.ts @@ -1,6 +1,23 @@ /* eslint-env jest */ import LegendRenderer from './LegendRenderer'; +import AbstractOutput from './AbstractOutput'; + +class MockOutput extends AbstractOutput { + constructor( + protected size: [number, number], + protected maxColumnWidth: number | null, + protected maxColumnHeight: number | null + ) { + super(size, maxColumnWidth, maxColumnHeight); + } + useContainer = jest.fn(); + useRoot = jest.fn(); + addTitle = jest.fn(); + addLabel = jest.fn(); + addImage = jest.fn(); + generate = jest.fn(); +} describe('LegendRenderer', () => { @@ -78,8 +95,8 @@ describe('LegendRenderer', () => { const renderer = new LegendRenderer({ size: [0, 0] }); - const dom: any = document.createElement('svg'); - const returnValue = await renderer.renderLegendItem(select( dom), { + const output = new MockOutput([0, 0], undefined, undefined); + const returnValue = await renderer.renderLegendItem(output, { title: 'Example', rule: { name: 'Item 1', @@ -91,12 +108,12 @@ describe('LegendRenderer', () => { expect(returnValue).toBeUndefined(); }); - it('renders a single non-empty legend item', done => { + it('renders a single non-empty legend item', async () => { const renderer = new LegendRenderer({ size: [0, 0] }); - const dom: any = document.createElement('svg'); - const result = renderer.renderLegendItem(select( dom), { + const output = new MockOutput([0, 0], undefined, undefined); + await renderer.renderLegendItem(output, { title: 'Example', rule: { name: 'Item 1', @@ -106,10 +123,8 @@ describe('LegendRenderer', () => { }] } }, [0, 0]); - result.then(() => { - expect(dom.querySelector('text').textContent).toBe('Example'); - done(); - }); + expect(output.useContainer).toHaveBeenCalledWith('Example'); + expect(output.addLabel).toHaveBeenCalledWith('Example', 50, 20); }); it('renders legend with a single non-empty legend item', done => { From 937a87e9d35562f5a3e0f04b6258b70545ccc69c Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Tue, 13 Sep 2022 11:33:11 +0200 Subject: [PATCH 8/9] Correction in comments Co-authored-by: Jan Suleiman --- src/LegendRenderer/LegendRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LegendRenderer/LegendRenderer.ts b/src/LegendRenderer/LegendRenderer.ts index f783d86..2ab0702 100644 --- a/src/LegendRenderer/LegendRenderer.ts +++ b/src/LegendRenderer/LegendRenderer.ts @@ -318,7 +318,7 @@ class LegendRenderer { } /** - * Renders the configured legend as an SVG image in the given target container. All pre-existing legends + * Renders the configured legend as an SVG or PNG image in the given target container. All pre-existing legends * will be removed. * @param {HTMLElement} target a node to append the svg to * @param format From b240f5553e6d82d718e3b410e9c28d58b71f612f Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Tue, 13 Sep 2022 12:44:53 +0200 Subject: [PATCH 9/9] Remove always succeeding tests --- src/LegendRenderer/PngOutput.spec.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/LegendRenderer/PngOutput.spec.ts b/src/LegendRenderer/PngOutput.spec.ts index cbd50fc..b440726 100644 --- a/src/LegendRenderer/PngOutput.spec.ts +++ b/src/LegendRenderer/PngOutput.spec.ts @@ -32,19 +32,6 @@ describe('PngOutput', () => { instrumentContext(output.context); }); - describe('#useContainer', () => { - it('does nothing', () => { - output.useContainer('My Container'); - // succeed - }); - }); - describe('#useRoot', () => { - it('does nothing', () => { - output.useContainer('My Container'); - output.useRoot(); - // succeed - }); - }); describe('#addTitle', () => { it('inserts a title', () => { output.addTitle('My Title', 100, 150);