From 166a1eeccd5f5c16cf04ad861b29eb011a119f99 Mon Sep 17 00:00:00 2001 From: Felix Sommer Date: Mon, 6 Jan 2025 16:49:02 +0100 Subject: [PATCH] PB-1297: make position of text labels configurable --- src/api/features/EditableFeature.class.js | 31 ++++- src/modules/i18n/locales/de.json | 1 + src/modules/i18n/locales/en.json | 1 + src/modules/i18n/locales/fr.json | 1 + src/modules/i18n/locales/it.json | 1 + src/modules/i18n/locales/rm.json | 1 + .../styling/DrawingStyleIconSelector.vue | 1 - .../styling/DrawingStylePlacementSelector.vue | 78 +++++++++++++ .../components/styling/FeatureStyleEdit.vue | 24 +++- src/store/modules/features.store.js | 33 +++++- src/utils/featureStyleUtils.js | 109 ++++++++++++++++-- src/utils/kmlUtils.js | 50 +++++++- tests/cypress/tests-e2e/drawing.cy.js | 27 +++++ 13 files changed, 343 insertions(+), 15 deletions(-) create mode 100644 src/modules/infobox/components/styling/DrawingStylePlacementSelector.vue diff --git a/src/api/features/EditableFeature.class.js b/src/api/features/EditableFeature.class.js index 989eac4c9..288e0c8b3 100644 --- a/src/api/features/EditableFeature.class.js +++ b/src/api/features/EditableFeature.class.js @@ -4,7 +4,14 @@ import { extractOlFeatureGeodesicCoordinates } from '@/api/features/features.api import SelectableFeature from '@/api/features/SelectableFeature.class' import { DEFAULT_ICON_URL_PARAMS } from '@/api/icon.api' import { DEFAULT_TITLE_OFFSET } from '@/api/icon.api' -import { allStylingColors, allStylingSizes, MEDIUM, RED } from '@/utils/featureStyleUtils' +import { + allStylingColors, + allStylingSizes, + allStylingTextPlacementsWithUnknown, + MEDIUM, + RED, + TOP, +} from '@/utils/featureStyleUtils' /** @enum */ export const EditableFeatureTypes = { @@ -33,6 +40,10 @@ export default class EditableFeature extends SelectableFeature { * @param {DrawingIcon} featureData.icon Icon that will be covering this feature, can be null * @param {FeatureStyleSize} featureData.iconSize Size of the icon (if defined) that will be * covering this feature + * + * @typedef {'top-left' | 'top' | 'top-right', | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right' | 'unknown'} TextPlacement + * @param {TextPlacement} featureData.textPlacement Size of the icon (if defined) that will be + * covering this feature */ constructor(featureData) { const { @@ -48,6 +59,7 @@ export default class EditableFeature extends SelectableFeature { fillColor = RED, icon = null, iconSize = MEDIUM, + textPlacement = TOP, } = featureData super({ id, coordinates, title, description, geometry, isEditable: true }) this._featureType = featureType @@ -59,6 +71,7 @@ export default class EditableFeature extends SelectableFeature { this._iconSize = iconSize this._geodesicCoordinates = null this._isDragged = false + this._textPlacement = textPlacement } /** @@ -108,6 +121,22 @@ export default class EditableFeature extends SelectableFeature { } } + /** @returns {TextPlacement} */ + get textPlacement() { + return this._textPlacement + } + + /** @param newPlacement {TextPlacement} */ + set textPlacement(newPlacement) { + if ( + newPlacement && + allStylingTextPlacementsWithUnknown.find((placement) => placement === newPlacement) + ) { + this._textPlacement = newPlacement + this.emitStylingChangeEvent('textPlacement') + } + } + /** @returns {FeatureStyleSize} */ get textSize() { return this._textSize diff --git a/src/modules/i18n/locales/de.json b/src/modules/i18n/locales/de.json index 33f59c21b..caddee3c6 100644 --- a/src/modules/i18n/locales/de.json +++ b/src/modules/i18n/locales/de.json @@ -418,6 +418,7 @@ "modify_new_vertex_measure": "Klicke, um einen Punkt hinzuzufügen.
Punkt verschieben: klicken und ziehen ", "modify_text_color_label": "Textfarbe", "modify_text_label": "Text", + "modify_text_placement_label": "Platzierung", "modify_text_size_label": "Grösse", "more_info": "Mehr dazu ...", "movie": "Zeitreihen (Multi-PDF)", diff --git a/src/modules/i18n/locales/en.json b/src/modules/i18n/locales/en.json index 521d34d6c..153a79440 100644 --- a/src/modules/i18n/locales/en.json +++ b/src/modules/i18n/locales/en.json @@ -418,6 +418,7 @@ "modify_new_vertex_measure": "Click to add a point
Click then drag to move the point", "modify_text_color_label": "Text color", "modify_text_label": "Text", + "modify_text_placement_label": "Platzierung", "modify_text_size_label": "Size", "more_info": "More info ...", "movie": "Time serie (Mutliple PDF)", diff --git a/src/modules/i18n/locales/fr.json b/src/modules/i18n/locales/fr.json index e452525e4..75dab6388 100644 --- a/src/modules/i18n/locales/fr.json +++ b/src/modules/i18n/locales/fr.json @@ -418,6 +418,7 @@ "modify_new_vertex_measure": " Cliquer pour ajouter un point
Cliquer puis bouger le curseur pour déplacer le point", "modify_text_color_label": "Couleur du texte", "modify_text_label": "Texte", + "modify_text_placement_label": "Emplacement", "modify_text_size_label": "Taille", "more_info": "Plus d'informations ...", "movie": "Série temporelle (Multi-PDF)", diff --git a/src/modules/i18n/locales/it.json b/src/modules/i18n/locales/it.json index 35ad54c17..5d5536e42 100644 --- a/src/modules/i18n/locales/it.json +++ b/src/modules/i18n/locales/it.json @@ -418,6 +418,7 @@ "modify_new_vertex_measure": "Cliccare per aggiungere un punto.
Cliccare quindi trascinare per spostare il punto", "modify_text_color_label": "Colore del testo", "modify_text_label": "Nota", + "modify_text_placement_label": "Posizione", "modify_text_size_label": "Grandezza", "more_info": "Più informazioni ...", "movie": "Serie storica (Multi-PDF)", diff --git a/src/modules/i18n/locales/rm.json b/src/modules/i18n/locales/rm.json index d2aa9801d..392531c6a 100644 --- a/src/modules/i18n/locales/rm.json +++ b/src/modules/i18n/locales/rm.json @@ -416,6 +416,7 @@ "modify_new_vertex_measure": "Cliccar per agiuntar punct. Spustar punct :cliccar e trair", "modify_text_color_label": "Colur dal text", "modify_text_label": "Etichetta", + "modify_text_placement_label": "Plazzament", "modify_text_size_label": "Grondezza", "more_info": "Ulteriuras infurmaziuns ...", "movie": "Seria temporala (Multi-PDF)", diff --git a/src/modules/infobox/components/styling/DrawingStyleIconSelector.vue b/src/modules/infobox/components/styling/DrawingStyleIconSelector.vue index 1518d3f86..75d44dc09 100644 --- a/src/modules/infobox/components/styling/DrawingStyleIconSelector.vue +++ b/src/modules/infobox/components/styling/DrawingStyleIconSelector.vue @@ -18,7 +18,6 @@ /> - +
+ +
+
+ +
+
+
+
+ + + + + diff --git a/src/modules/infobox/components/styling/FeatureStyleEdit.vue b/src/modules/infobox/components/styling/FeatureStyleEdit.vue index c3dfd0855..a7046e073 100644 --- a/src/modules/infobox/components/styling/FeatureStyleEdit.vue +++ b/src/modules/infobox/components/styling/FeatureStyleEdit.vue @@ -10,6 +10,7 @@ import FeatureAreaInfo from '@/modules/infobox/components/FeatureAreaInfo.vue' import DrawingStyleColorSelector from '@/modules/infobox/components/styling/DrawingStyleColorSelector.vue' import DrawingStyleIconSelector from '@/modules/infobox/components/styling/DrawingStyleIconSelector.vue' import DrawingStyleMediaLink from '@/modules/infobox/components/styling/DrawingStyleMediaLink.vue' +import DrawingStylePositionSelector from '@/modules/infobox/components/styling/DrawingStylePlacementSelector.vue' import DrawingStylePopoverButton from '@/modules/infobox/components/styling/DrawingStylePopoverButton.vue' import DrawingStyleSizeSelector from '@/modules/infobox/components/styling/DrawingStyleSizeSelector.vue' import DrawingStyleTextColorSelector from '@/modules/infobox/components/styling/DrawingStyleTextColorSelector.vue' @@ -36,7 +37,6 @@ const { feature, readOnly } = toRefs(props) const title = ref(feature.value.title) const description = ref(feature.value.description) const mediaPopovers = ref(null) - const isEditingText = computed(() => { const titleElement = document.getElementById('drawing-style-feature-title') const descriptionElement = document.getElementById('drawing-style-feature-description') @@ -95,6 +95,10 @@ function updateFeatureTitle() { title: title.value.trim(), ...dispatcher, }) + // Update the text offset if the feature is a marker + if (feature.value.featureType === EditableFeatureTypes.MARKER) { + updateTextOffset() + } } function updateFeatureDescription() { @@ -129,6 +133,14 @@ function onTextSizeChange(textSize) { store.dispatch('changeFeatureTextSize', { feature: feature.value, textSize, ...dispatcher }) updateTextOffset() } +function onPlacementChange(textPlacement) { + store.dispatch('changeFeatureTextPlacement', { + feature: feature.value, + textPlacement, + ...dispatcher, + }) + updateTextOffset() +} function onTextColorChange(textColor) { store.dispatch('changeFeatureTextColor', { feature: feature.value, textColor, ...dispatcher }) } @@ -157,7 +169,9 @@ function updateTextOffset() { feature.value.textSize.textScale, feature.value.iconSize.iconScale, feature.value.icon.anchor, - feature.value.icon.size + feature.value.icon.size, + feature.value.textPlacement, + title.value ) store.dispatch('changeFeatureTextOffset', { @@ -275,6 +289,12 @@ function mediaTypes() { :current-size="feature.textSize" @change="onTextSizeChange" /> + position === textPlacement + ) + if (wantedPlacement && selectedFeature && selectedFeature.isEditable) { + commit('changeFeatureTextPlacement', { + feature: selectedFeature, + textPlacement: wantedPlacement, + dispatcher, + }) + } + }, /** * Changes the text offset of the feature. Only change the text offset if the feature is * editable and part of the currently selected features @@ -735,6 +763,9 @@ export default { changeFeatureTextSize(state, { feature, textSize }) { feature.textSize = textSize }, + changeFeatureTextPlacement(state, { feature, textPlacement }) { + feature.textPlacement = textPlacement + }, changeFeatureTextOffset(state, { feature, textOffset }) { feature.textOffset = textOffset }, diff --git a/src/utils/featureStyleUtils.js b/src/utils/featureStyleUtils.js index 4722dacec..3978b5383 100644 --- a/src/utils/featureStyleUtils.js +++ b/src/utils/featureStyleUtils.js @@ -4,7 +4,6 @@ import Style from 'ol/style/Style' import { EditableFeatureTypes } from '@/api/features/EditableFeature.class' import { DEFAULT_TITLE_OFFSET } from '@/api/icon.api' -import log from '@/utils/logging' import { dashedRedStroke, whiteSketchFill } from '@/utils/styleUtils.js' /** A color that can be used to style a feature (comprised of a fill and a border color) */ @@ -70,7 +69,8 @@ export const WHITE = new FeatureStyleColor('white', '#ffffff', '#000000') export const YELLOW = new FeatureStyleColor('yellow', '#ffff00', '#000000') export const allStylingColors = [BLACK, BLUE, GRAY, GREEN, ORANGE, RED, WHITE, YELLOW] - +export const FEATURE_FONT_SIZE = 16 +export const FEATURE_FONT = 'Helvetica' /** * Representation of a size for feature style * @@ -118,7 +118,7 @@ export class FeatureStyleSize { } get font() { - return `normal ${16 * this.textScale}px Helvetica` + return `normal ${FEATURE_FONT_SIZE * this.textScale}px ${FEATURE_FONT}` } } @@ -132,12 +132,35 @@ export const MEDIUM = new FeatureStyleSize('medium_size', 1.5, 0.75) export const LARGE = new FeatureStyleSize('large_size', 2.0, 1) export const EXTRA_LARGE = new FeatureStyleSize('extra_large_size', 2.5, 1.25) +export const LEFT = 'left' +export const RIGHT = 'right' +export const TOP = 'top' +export const BOTTOM = 'bottom' +export const CENTER = 'center' +export const UNKNOWN = 'unknown' +export const TOP_LEFT = 'top-left' +export const TOP_RIGHT = 'top-right' +export const BOTTOM_LEFT = 'bottom-left' +export const BOTTOM_RIGHT = 'bottom-right' + /** * List of all available sizes for drawing style * * @type {FeatureStyleSize[]} */ export const allStylingSizes = [SMALL, MEDIUM, LARGE, EXTRA_LARGE] +export const allStylingTextPlacements = [ + TOP_LEFT, + TOP, + TOP_RIGHT, + LEFT, + CENTER, + RIGHT, + BOTTOM_LEFT, + BOTTOM, + BOTTOM_RIGHT, +] +export const allStylingTextPlacementsWithUnknown = [...allStylingTextPlacements, UNKNOWN] /** * Get Feature style from feature @@ -212,23 +235,91 @@ export function getTextColor(style) { * @param {Number} iconScale Icon scaling * @param {Array} anchor Relative position of Anchor * @param {Array} iconSize Absolute size of icon in pixel - * @returns {Array | null} Returns the feature label offset + * + * @typedef {'top-left' | 'top' | 'top-right', | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right' | 'unknown'} TextPlacement + * @param {TextPlacement} textPlacement Absolute position of text in pixel + * @returns {Array} Returns the feature label offset */ -export function calculateTextOffset(textScale, iconScale, anchor, iconSize) { +export function calculateTextOffset(textScale, iconScale, anchor, iconSize, textPlacement, text) { if (!iconScale) { return DEFAULT_TITLE_OFFSET } + const [textPlacementX, textPlacementY] = calculateTextXYOffset( + textScale, + iconScale, + anchor, + iconSize, + text + ) + return calculateTextOffsetFromPlacement(textPlacementX, textPlacementY, textPlacement) +} + +/** + * Calculate the text X and Y offset that can be applied to the text depending on the text position + * + * @param {Number} textScale Text scaling + * @param {Number} iconScale Icon scaling + * @param {Array} anchor Relative position of Anchor + * @param {Array} iconSize Absolute size of icon in pixel + * @param {String} text Text to display + * @returns {Array} Returns the default X and Y label offset in pixel + */ +export function calculateTextXYOffset(textScale, iconScale, anchor, iconSize, text) { const fontSize = 11 - let anchorScale = anchor ? anchor[1] * 2 : 1 + const anchorScale = anchor ? anchor[1] * 2 : 1 const iconOffset = 0.5 * iconScale * anchorScale * iconSize[1] const textOffset = 0.5 * fontSize * textScale + const textWidth = calculateFeatureTextWidth(text, textScale) const defaultOffset = 5 - const offset = [0, -(defaultOffset + iconOffset + textOffset)] - log.debug('title offset of feature is calculated to be : ', offset) - return offset + return [ + defaultOffset + iconOffset + textOffset, + defaultOffset + iconOffset + textOffset + textWidth / 2, // / 2 because the text is centered + ] +} + +/** + * Calculate the text offset from the text placement and the default offset + * + * @param {Number} defaultXOffset Default X offset + * @param {Number} defaultYOffset Default Y offset + * @param {String} placement Text placement + * @returns {Array} Returns the default X and Y label offset in pixel + */ +export function calculateTextOffsetFromPlacement(defaultXOffset, defaultYOffset, placement) { + const offsets = { + [TOP_LEFT]: [-defaultXOffset, -defaultYOffset], + [TOP]: [0, -defaultYOffset], + [TOP_RIGHT]: [defaultXOffset, -defaultYOffset], + [LEFT]: [-defaultXOffset, 0], + [CENTER]: [0, 0], + [RIGHT]: [defaultXOffset, 0], + [BOTTOM_LEFT]: [-defaultXOffset, defaultYOffset], + [BOTTOM]: [0, defaultYOffset], + [BOTTOM_RIGHT]: [defaultXOffset, defaultYOffset], + } + + return offsets[placement] || [0, 0] +} + +/** + * Calculates the width of a feature text given a text and a text scale + * + * @param {String} text + * @param {Number} textScale + * @returns + */ +export function calculateFeatureTextWidth(text, textScale) { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + // In unit tests the context is not available + if (!context) { + return 0 + } + context.font = `normal ${FEATURE_FONT_SIZE * textScale}px ${FEATURE_FONT}` + return context.measureText(text).width } /** diff --git a/src/utils/kmlUtils.js b/src/utils/kmlUtils.js index 505c850df..425e1a7ed 100644 --- a/src/utils/kmlUtils.js +++ b/src/utils/kmlUtils.js @@ -18,6 +18,9 @@ import KmlStyles from '@/api/layers/KmlStyles.enum' import { WGS84 } from '@/utils/coordinates/coordinateSystems' import { allStylingSizes, + allStylingTextPlacements, + calculateTextOffsetFromPlacement, + calculateTextXYOffset, geoadminStyleFunction, getFeatureStyleColor, getStyle, @@ -25,6 +28,7 @@ import { getTextSize, RED, SMALL, + UNKNOWN, } from '@/utils/featureStyleUtils' import { GeodesicGeometries } from '@/utils/geodesicManager' import log from '@/utils/logging' @@ -413,7 +417,14 @@ export function getEditableFeatureFromKmlFeature(kmlFeature, availableIconSets) const geometry = new GeoJSON().writeGeometryObject(kmlFeature.getGeometry()) const coordinates = extractOlFeatureCoordinates(kmlFeature) - + const textPlacement = detectTextPlacement( + textScale, + iconStyle?.getScale(), + icon?.anchor, + icon?.size, + title, + textOffset + ) if (iconArgs?.isLegacy && iconStyle && icon) { // The legacy drawing uses icons from old URLs, some of them have already been removed // like the versioned URLs (/{version}/img/maki/{image}-{size}@{scale}x.png) while others @@ -452,9 +463,46 @@ export function getEditableFeatureFromKmlFeature(kmlFeature, availableIconSets) fillColor, icon, iconSize, + textPlacement, }) } +/** + * Detect the feature text placement based on the icon and text size + * + * @param {Number} textScale Text scaling + * @param {Number} iconScale Icon scaling + * @param {Array} anchor Relative position of Anchor + * @param {Array} iconSize Absolute size of icon in pixel + * @param {String} text Text to display + * @param {Array} currentTextOffset Current text offset of the kml feature + * @returns {TextPlacement} Returns the text placement or undefined if the icon is not a marker + */ +function detectTextPlacement(textScale, iconScale, anchor, iconSize, text, currentTextOffset) { + if (!text || !textScale || !iconScale || !anchor || !iconSize) { + return UNKNOWN + } + const [textPlacementX, textPlacementY] = calculateTextXYOffset( + textScale, + iconScale, + anchor, + iconSize, + text + ) + + for (let placementOption of allStylingTextPlacements) { + const [xOffset, yOffset] = calculateTextOffsetFromPlacement( + textPlacementX, + textPlacementY, + placementOption + ) + if (xOffset === currentTextOffset[0] && yOffset === currentTextOffset[1]) { + return placementOption + } + } + return UNKNOWN +} + const nonGeoadminIconUrls = new Set() export function iconUrlProxyFy(url, corsIssueCallback = null, httpIssueCallBack = null) { // We only proxyfy URL that are not from our backend. diff --git a/tests/cypress/tests-e2e/drawing.cy.js b/tests/cypress/tests-e2e/drawing.cy.js index c7765b9cb..2a9edc39f 100644 --- a/tests/cypress/tests-e2e/drawing.cy.js +++ b/tests/cypress/tests-e2e/drawing.cy.js @@ -238,6 +238,32 @@ describe('Drawing module tests', () => { // changing/editing the title of this marker testTitleEdit() + // changing text placement + cy.log('Test text placement and offset') + cy.get('[data-cy="drawing-style-text-button"]').click() + cy.get('[data-cy="drawing-style-placement-selector-top-left"]').click() + cy.readStoreValue('getters.selectedFeatures[0].textPlacement').should( + 'eq', + 'top-left' + ) + cy.readStoreValue('getters.selectedFeatures[0].textOffset').then((offset) => { + cy.wrap(offset[0]).should('be.lessThan', 0) + cy.wrap(offset[1]).should('be.lessThan', 0) + }) + + cy.wait('@update-kml') + .its('request') + .should((request) => + checkKMLRequest(request, [ + new RegExp( + `` + + `(-?\\d+\\.\\d+),(-?\\d+\\.\\d+)` + // Test if both values are floats + `` + ), + ]) + ) + cy.get('[data-cy="drawing-style-text-button"]').click() + // changing/editing the description of this marker const description = 'A description for this marker' cy.get('[data-cy="drawing-style-feature-description"]').type(description) @@ -520,6 +546,7 @@ describe('Drawing module tests', () => { // Opening text style edit popup cy.get('[data-cy="drawing-style-text-button"]').click() + cy.get('[data-cy="drawing-style-placement-selector-top-left"]').should('not.exist') cy.get('[data-cy="drawing-style-text-popup"]').should('be.visible') // all available colors must have a dedicated element/button