Skip to content

Commit 7f355c4

Browse files
mgermeriegchoqueux
authored andcommitted
feature(Style): add support for custom icon in labels
1 parent fe2a2d9 commit 7f355c4

File tree

6 files changed

+112
-45
lines changed

6 files changed

+112
-45
lines changed

examples/source_file_kml_raster.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
haloWidth: 1,
7373
transform: 'uppercase',
7474
},
75+
icon: {
76+
anchor: 'bottom',
77+
},
7578
});
7679

7780
var kmlLayer = new itowns.ColorLayer('Kml', {

src/Core/Label.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ class Label extends THREE.Object3D {
102102
this.styleOffset = [0, 0];
103103
}
104104

105+
this.icon = this.content.getElementsByClassName('itowns-icon')[0];
106+
this.iconOffset = { left: 0, right: 0, top: 0, bottom: 0 };
107+
105108
this.zoom = {
106109
min: style.zoom && style.zoom.min != undefined ? style.zoom.min : 2,
107110
max: style.zoom && style.zoom.max != undefined ? style.zoom.max : 24,
@@ -130,15 +133,36 @@ class Label extends THREE.Object3D {
130133
this.boundaries.right = x + this.offset.right + this.padding;
131134
this.boundaries.top = y + this.offset.top - this.padding;
132135
this.boundaries.bottom = y + this.offset.bottom + this.padding;
136+
137+
// The boundaries of the label are the union of the boundaries of the text
138+
// and the boundaries of the icon, if it exists.
139+
// Checking if this.icon is not only zeros is mandatory, to prevent case
140+
// when a boundary is set to x or y coordinate
141+
if (
142+
this.iconOffset.left !== 0 && this.iconOffset.right !== 0
143+
&& this.iconOffset.top !== 0 && this.iconOffset.bottom !== 0
144+
) {
145+
this.boundaries.left = Math.min(this.boundaries.left, x + this.iconOffset.left);
146+
this.boundaries.right = Math.max(this.boundaries.right, x + this.iconOffset.right);
147+
this.boundaries.top = Math.min(this.boundaries.top, y + this.iconOffset.top);
148+
this.boundaries.bottom = Math.max(this.boundaries.bottom, y + this.iconOffset.bottom);
149+
}
133150
}
134151
}
135152

136153
updateCSSPosition() {
154+
// translate all content according to its given anchor
137155
this.content.style[STYLE_TRANSFORM] = `translate(${
138156
this.projectedPosition.x + this.offset.left
139157
}px, ${
140158
this.projectedPosition.y + this.offset.top
141159
}px)`;
160+
161+
// translate possible icon inside content to cancel anchoring on it, so that it can later be positioned
162+
// according to its own anchor
163+
if (this.icon) {
164+
this.icon.style[STYLE_TRANSFORM] = `translate(${-this.offset.left}px, ${-this.offset.top}px)`;
165+
}
142166
}
143167

144168
/**
@@ -157,6 +181,16 @@ class Label extends THREE.Object3D {
157181
};
158182
this.offset.right = this.offset.left + width;
159183
this.offset.bottom = this.offset.top + height;
184+
185+
if (this.icon) {
186+
rect = this.icon.getBoundingClientRect();
187+
this.iconOffset = {
188+
left: Math.floor(rect.x),
189+
top: Math.floor(rect.y),
190+
right: Math.ceil(rect.x + rect.width),
191+
bottom: Math.ceil(rect.y + rect.height),
192+
};
193+
}
160194
}
161195
}
162196

src/Core/Style.js

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,15 @@ function defineStyleProperty(style, category, name, value, defaultValue) {
260260
* @property {number|function} text.haloBlur - The blur value of the halo, in pixels.
261261
* Default is `0`.
262262
*
263+
* @property {Object} icon - Defines the appearance of icons attached to label.
264+
* @property {String} icon.source - The url of the icons' image file.
265+
* @property {String} icon.key - The key of the icons' image in a vector tile data set.
266+
* @property {string} [icon.anchor='center'] - The anchor of the icon compared to the label position.
267+
* Can be `left`, `bottom`, `right`, `center`, `top-left`, `top-right`, `bottom-left`
268+
* or `bottom-right`.
269+
* @property {number} icon.size - If the icon's image is passed with `icon.source` or
270+
* `icon.key`, it size when displayed on screen is multiplied by `icon.size`. Default is `1`.
271+
*
263272
* @example
264273
* const style = new itowns.Style({
265274
* stroke: { color: 'red' },
@@ -295,13 +304,15 @@ class Style {
295304
stroke: {},
296305
point: {},
297306
text: {},
307+
icon: {},
298308
};
299309

300310
params.zoom = params.zoom || {};
301311
params.fill = params.fill || {};
302312
params.stroke = params.stroke || {};
303313
params.point = params.point || {};
304314
params.text = params.text || {};
315+
params.icon = params.icon || {};
305316

306317
this.zoom = {};
307318
defineStyleProperty(this, 'zoom', 'min', params.zoom.min);
@@ -354,6 +365,12 @@ class Style {
354365
defineStyleProperty(this, 'text', 'haloColor', params.text.haloColor, '#000000');
355366
defineStyleProperty(this, 'text', 'haloWidth', params.text.haloWidth, 0);
356367
defineStyleProperty(this, 'text', 'haloBlur', params.text.haloBlur, 0);
368+
369+
this.icon = {};
370+
defineStyleProperty(this, 'icon', 'source', params.icon.source);
371+
defineStyleProperty(this, 'icon', 'key', params.icon.key);
372+
defineStyleProperty(this, 'icon', 'anchor', params.icon.anchor, 'center');
373+
defineStyleProperty(this, 'icon', 'size', params.icon.size, 1);
357374
}
358375

359376
/**
@@ -407,6 +424,7 @@ class Style {
407424
Object.assign(this.stroke, style.stroke);
408425
Object.assign(this.point, style.point);
409426
Object.assign(this.text, style.text);
427+
Object.assign(this.icon, style.icon);
410428
return this;
411429
}
412430

@@ -438,7 +456,7 @@ class Style {
438456
this.text.size = properties['label-size'];
439457

440458
if (properties.icon) {
441-
this.icon = { image: properties.icon, size: 1 };
459+
this.icon.source = properties.icon;
442460
}
443461
} else {
444462
this.stroke.color = properties.stroke;
@@ -537,7 +555,7 @@ class Style {
537555
// additional icon
538556
const key = readVectorProperty(layer.layout['icon-image']);
539557
if (key) {
540-
this.icon = { key };
558+
this.icon.key = key;
541559
this.icon.size = readVectorProperty(layer.layout['icon-size']) || 1;
542560
}
543561
}
@@ -573,14 +591,12 @@ class Style {
573591
domElement.setAttribute('data-before', domElement.textContent);
574592
}
575593

576-
if (!this.icon) {
594+
if (!this.icon.source && !this.icon.key) {
577595
return;
578596
}
579597

580-
const image = this.icon.image;
581-
598+
const image = this.icon.source;
582599
const size = this.icon.size;
583-
584600
const key = this.icon.key;
585601

586602
let icon = cacheStyle.get(image || key, size);
@@ -591,45 +607,51 @@ class Style {
591607
} else {
592608
icon = getImage(image);
593609
}
594-
icon.style.position = 'absolute';
595610
cacheStyle.set(icon, image || key, size);
596611
}
597612

598613
const addIcon = () => {
599614
const cIcon = icon.cloneNode();
600-
cIcon.width *= size;
601-
cIcon.height *= size;
602-
switch (this.text.anchor) {
615+
616+
cIcon.setAttribute('class', 'itowns-icon');
617+
618+
cIcon.width = icon.width * this.icon.size;
619+
cIcon.height = icon.height * this.icon.size;
620+
cIcon.style.position = 'absolute';
621+
cIcon.style.top = '0';
622+
cIcon.style.left = '0';
623+
624+
switch (this.icon.anchor) { // center by default
603625
case 'left':
604-
cIcon.style.right = `calc(100% - ${cIcon.width * 0.5}px)`;
605-
cIcon.style.top = `calc(50% - ${cIcon.height * 0.5}px)`;
626+
cIcon.style.top = `${-0.5 * cIcon.height}px`;
606627
break;
607628
case 'right':
608-
cIcon.style.top = `calc(50% - ${cIcon.height * 0.5}px)`;
629+
cIcon.style.top = `${-0.5 * cIcon.height}px`;
630+
cIcon.style.left = `${-cIcon.width}px`;
609631
break;
610632
case 'top':
611-
cIcon.style.right = `calc(50% - ${cIcon.width * 0.5}px)`;
633+
cIcon.style.left = `${-0.5 * cIcon.width}px`;
612634
break;
613635
case 'bottom':
614-
cIcon.style.top = `calc(100% - ${cIcon.height * 0.5}px)`;
615-
cIcon.style.right = `calc(50% - ${cIcon.width * 0.5}px)`;
636+
cIcon.style.top = `${-cIcon.height}px`;
637+
cIcon.style.left = `${-0.5 * cIcon.width}px`;
616638
break;
617639
case 'bottom-left':
618-
cIcon.style.top = `calc(100% - ${cIcon.height * 0.5}px)`;
619-
cIcon.style.right = `calc(100% - ${cIcon.width * 0.5}px)`;
640+
cIcon.style.top = `${-cIcon.height}px`;
620641
break;
621642
case 'bottom-right':
622-
cIcon.style.top = `calc(100% - ${cIcon.height * 0.5}px)`;
643+
cIcon.style.top = `${-cIcon.height}px`;
644+
cIcon.style.left = `${-cIcon.width}px`;
623645
break;
624646
case 'top-left':
625-
cIcon.style.right = `calc(100% - ${cIcon.width * 0.5}px)`;
626647
break;
627648
case 'top-right':
649+
cIcon.style.left = `${-cIcon.width}px`;
628650
break;
629651
case 'center':
630652
default:
631-
cIcon.style.top = `calc(50% - ${cIcon.height * 0.5}px)`;
632-
cIcon.style.right = `calc(50% - ${cIcon.width * 0.5}px)`;
653+
cIcon.style.top = `${-0.5 * cIcon.height}px`;
654+
cIcon.style.left = `${-0.5 * cIcon.width}px`;
633655
break;
634656
}
635657

@@ -700,6 +722,6 @@ style_properties.fill = Object.keys(style.fill);
700722
style_properties.stroke = Object.keys(style.stroke);
701723
style_properties.point = Object.keys(style.point);
702724
style_properties.text = Object.keys(style.text);
703-
style_properties.icon = ['image', 'size', 'key'];
725+
style_properties.icon = Object.keys(style.icon);
704726

705727
export default Style;

src/Layer/LabelLayer.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,9 @@ class LabelLayer extends Layer {
9797
const context = { globals, properties: () => g.properties };
9898
if (!geometryField && !featureField && !layerField) {
9999
// Check if there is an icon, with no text
100-
if (!(g.properties.style && g.properties.style.icon)
101-
&& !(f.style && f.style.icon)
102-
&& !(this.style && this.style.icon)) {
100+
if (!(g.properties.style && (g.properties.style.icon.source || g.properties.style.icon.key))
101+
&& !(f.style && (f.style.icon.source || f.style.icon.key))
102+
&& !(this.style && (this.style.icon.source || this.style.icon.key))) {
103103
return;
104104
}
105105
} else if (geometryField) {

test/unit/bootstrap.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ class DOMElement {
5757
}
5858
}
5959
createSVGMatrix() {}
60+
getElementsByClassName(className) {
61+
return [this.children.find(element => element.class === className)];
62+
}
6063
}
6164

6265
// Mock document object for Mocha.

test/unit/label.js

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,40 +78,44 @@ describe('Label', function () {
7878
const img = cacheStyle.get('icon', 1);
7979
img.complete = true;
8080
img.emitEvent('load');
81-
assert.equal(label.content.children[0].style.right, 'calc(50% - 5px)');
82-
assert.equal(label.content.children[0].style.top, 'calc(50% - 5px)');
81+
assert.equal(label.content.children[0].style.left, `${-0.5 * img.width}px`);
82+
assert.equal(label.content.children[0].style.top, `${-0.5 * img.height}px`);
8383

8484

85-
style.text.anchor = 'left';
85+
style.icon.anchor = 'left';
8686
label = new Label('', c, style);
87-
assert.equal(label.content.children[0].style.right, 'calc(100% - 5px)');
88-
assert.equal(label.content.children[0].style.top, 'calc(50% - 5px)');
87+
assert.equal(label.content.children[0].style.left, '0');
88+
assert.equal(label.content.children[0].style.top, `${-0.5 * img.height}px`);
8989

90-
style.text.anchor = 'right';
90+
style.icon.anchor = 'right';
9191
label = new Label('', c, style);
92-
assert.equal(label.content.children[0].style.top, 'calc(50% - 5px)');
92+
assert.equal(label.content.children[0].style.left, `${-img.width}px`);
93+
assert.equal(label.content.children[0].style.top, `${-0.5 * img.height}px`);
9394

94-
style.text.anchor = 'top';
95+
style.icon.anchor = 'top';
9596
label = new Label('', c, style);
96-
assert.equal(label.content.children[0].style.right, 'calc(50% - 5px)');
97+
assert.equal(label.content.children[0].style.left, `${-0.5 * img.width}px`);
98+
assert.equal(label.content.children[0].style.top, '0');
9799

98-
style.text.anchor = 'bottom';
100+
style.icon.anchor = 'bottom';
99101
label = new Label('', c, style);
100-
assert.equal(label.content.children[0].style.top, 'calc(100% - 5px)');
101-
assert.equal(label.content.children[0].style.right, 'calc(50% - 5px)');
102+
assert.equal(label.content.children[0].style.left, `${-0.5 * img.width}px`);
103+
assert.equal(label.content.children[0].style.top, `${-img.height}px`);
102104

103-
style.text.anchor = 'bottom-left';
105+
style.icon.anchor = 'bottom-left';
104106
label = new Label('', c, style);
105-
assert.equal(label.content.children[0].style.right, 'calc(100% - 5px)');
106-
assert.equal(label.content.children[0].style.top, 'calc(100% - 5px)');
107+
assert.equal(label.content.children[0].style.left, '0');
108+
assert.equal(label.content.children[0].style.top, `${-img.height}px`);
107109

108-
style.text.anchor = 'bottom-right';
110+
style.icon.anchor = 'bottom-right';
109111
label = new Label('', c, style);
110-
assert.equal(label.content.children[0].style.top, 'calc(100% - 5px)');
112+
assert.equal(label.content.children[0].style.left, `${-img.width}px`);
113+
assert.equal(label.content.children[0].style.top, `${-img.height}px`);
111114

112-
style.text.anchor = 'top-left';
115+
style.icon.anchor = 'top-left';
113116
label = new Label('', c, style);
114-
assert.equal(label.content.children[0].style.right, 'calc(100% - 5px)');
117+
assert.equal(label.content.children[0].style.left, '0');
118+
assert.equal(label.content.children[0].style.top, '0');
115119
});
116120

117121
it('should hide the DOM', function () {
@@ -125,6 +129,7 @@ describe('Label', function () {
125129
});
126130

127131
it('initializes the dimensions', function () {
132+
style.text.anchor = 'top-left';
128133
label = new Label('', c, style);
129134
assert.equal(label.offset, undefined);
130135

0 commit comments

Comments
 (0)