290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
"use strict";
|
|
// This class contains the logic for label creating for the mapview component
|
|
// It allows for clustering the labels and show a new lable when two or more labels overlap
|
|
class MapViewLabelController {
|
|
map;
|
|
canvas;
|
|
svg;
|
|
mapLabels;
|
|
// Takes a mapview map object and uses it to get the canvas container and create the initial svg element with d3.js
|
|
constructor(map) {
|
|
this.map = map;
|
|
this.canvas = map.getCanvasContainer();
|
|
this.svg = d3.select(this.canvas).append("svg")
|
|
.attr("width", "100%").attr("height", "100%")
|
|
.attr("z-index", "10").style("position", "absolute").style("pointer-events", "none");
|
|
}
|
|
// Clears the current labels and redraws the labels for the geodata features passed as arguments
|
|
refreshLabels(geoData) {
|
|
d3.selectAll("g.label, g.labelclone").remove();
|
|
this.drawLabels(geoData);
|
|
}
|
|
// For each geodata feature executes the logic to append a new label to the svg elements.
|
|
// It also register the events to update the label position when the user interacts with the map.
|
|
drawLabels(geoData) {
|
|
this.mapLabels = this.svg.selectAll("g.label")
|
|
.data(geoData.features)
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", "label")
|
|
.each((d, i, nodes) => {
|
|
const g = d3.select(nodes[i]);
|
|
this.appendLabels(d, g);
|
|
});
|
|
// Call the update function
|
|
this.updateLabelsPosition();
|
|
this.map.on("viewreset", () => this.updateLabelsPosition());
|
|
this.map.on("move", () => this.updateLabelsPosition());
|
|
this.map.on("moveend", () => this.updateLabelsPosition());
|
|
}
|
|
// Add labels with background
|
|
appendLabels(geoData, graphElem) {
|
|
const maxWidth = 100;
|
|
const name = geoData.properties?.name ?? "";
|
|
const [lon, lat] = geoData.geometry.coordinates;
|
|
graphElem.attr("data-lon", lon)
|
|
.attr("data-lat", lat);
|
|
graphElem.select("text").remove();
|
|
graphElem.select("rect").remove();
|
|
// Append the text
|
|
const text = graphElem.append("text")
|
|
.attr("feature-name", name)
|
|
.attr("font-size", "12px")
|
|
.attr("fill", "black")
|
|
.attr("text-anchor", "middle")
|
|
.attr("x", 0);
|
|
// Manually wrap the text
|
|
const words = name.match(/[^\s]+|\n/g);
|
|
let line = [];
|
|
let currHeight = 0;
|
|
let lineHeight = 1.1; // ems
|
|
const x = 0;
|
|
const y = 0;
|
|
let tspan = text.append("tspan")
|
|
.attr("x", x)
|
|
.attr("y", y)
|
|
.attr("dy", '0em');
|
|
for (let i = 0; i < words.length; i++) {
|
|
line.push(words[i]);
|
|
tspan.text(line.join(" "));
|
|
// If text exceeds maxWidth, remove last word and start new line
|
|
if (tspan.node().getComputedTextLength() > maxWidth || words[i] == '\n') {
|
|
line.pop();
|
|
tspan.text(line.join(" "));
|
|
line = [words[i]];
|
|
lineHeight = words[i] == '\n' ? 1.4 : 1.1;
|
|
currHeight += lineHeight;
|
|
tspan = text.append("tspan")
|
|
.attr("x", x)
|
|
.attr("y", y)
|
|
.attr("dy", `${currHeight}em`)
|
|
.text(words[i]);
|
|
}
|
|
}
|
|
// Measure the text
|
|
const bbox = text.node().getBBox();
|
|
// Append rect behind text
|
|
graphElem.insert("rect", "text")
|
|
.attr("x", bbox.x - 4)
|
|
.attr("y", bbox.y - 2)
|
|
.attr("width", bbox.width + 8)
|
|
.attr("height", bbox.height + 4)
|
|
.attr("fill", "white")
|
|
.attr("stroke", "black")
|
|
.attr("stroke-width", 0.5)
|
|
.attr("rx", 4)
|
|
.attr("ry", 4);
|
|
graphElem.attr("data-bbox-width", bbox.width);
|
|
graphElem.attr("data-bbox-height", bbox.height);
|
|
graphElem.attr("label-anchor", "top");
|
|
}
|
|
// Update d3 shapes' positions to the map's current state
|
|
updateLabelsPosition() {
|
|
if (this.mapLabels !== null && this.mapLabels !== undefined) {
|
|
// The original cloned svg elements are removed and new ones are created
|
|
d3.selectAll("g.labelclone").remove();
|
|
this.mapLabels.each((_, i, nodes) => {
|
|
const clone = nodes[i].cloneNode(true);
|
|
clone.setAttribute("class", "labelclone");
|
|
clone.setAttribute("data-elementid", i.toString());
|
|
clone.removeAttribute("style");
|
|
d3.select(nodes[i].parentNode).append(() => clone);
|
|
});
|
|
d3.selectAll("g.label").style("display", "none");
|
|
// Set the map projection transformation and remove not visible nodes
|
|
const canvas = d3.select(".mapboxgl-canvas");
|
|
const canvas_width = +canvas.attr("width");
|
|
const canvas_height = +canvas.attr("height");
|
|
d3.selectAll("g.labelclone").each((_, i, nodes) => {
|
|
const lon = Number(nodes[i].getAttribute("data-lon"));
|
|
const lat = Number(nodes[i].getAttribute("data-lat"));
|
|
const p = this.projectCoordinatesToPosition([lon, lat]);
|
|
if (p.x > 0 && p.y > 0 && p.x <= canvas_width && p.y <= canvas_height) {
|
|
const translateVal = `translate(${p.x}, ${p.y})`;
|
|
nodes[i].setAttribute("transform", translateVal);
|
|
}
|
|
else {
|
|
nodes[i].remove();
|
|
}
|
|
});
|
|
// Check if the marker overlaps and if it does create a group merging labels
|
|
const labelGroups = [];
|
|
d3.selectAll("g.labelclone").each((_, i, nodes) => {
|
|
const node = nodes[i];
|
|
let groupFound = false;
|
|
for (let group of labelGroups) {
|
|
for (let j = 0; j < group.length; j++) {
|
|
const recta = this.getOverlapZone(node);
|
|
const rectb = this.getOverlapZone(group[j]);
|
|
const ol_result = this.isOverlapping(recta, rectb);
|
|
if (ol_result.overlapping) {
|
|
if (ol_result.bottom || ol_result.right) {
|
|
group.unshift(node);
|
|
}
|
|
else {
|
|
group.push(node);
|
|
}
|
|
groupFound = true;
|
|
break;
|
|
}
|
|
}
|
|
if (groupFound) {
|
|
break;
|
|
}
|
|
}
|
|
// If any label group was found then create a new one with the label node
|
|
if (!groupFound) {
|
|
labelGroups.push([node]);
|
|
}
|
|
});
|
|
// Merge labels in overlapping groups
|
|
for (const group of labelGroups) {
|
|
if (group.length === 1) {
|
|
continue;
|
|
}
|
|
this.createClusteredLabel(group);
|
|
}
|
|
this.setFinalPositioning();
|
|
}
|
|
}
|
|
// Gets the lat-long attributes from a svg group element
|
|
getElementLatLon(el) {
|
|
const lat = +el.getAttribute("data-lat");
|
|
const lon = +el.getAttribute("data-lon");
|
|
return { lat: lat, lon: lon };
|
|
}
|
|
// Project GeoJSON coordinate to the map's current state
|
|
projectCoordinatesToPosition(c) {
|
|
return map.project(new mapboxgl.LngLat(+c[0], +c[1]));
|
|
}
|
|
// Get the x-y properties in a svg group element
|
|
getElementTransform(el) {
|
|
const transform = el.getAttribute("transform");
|
|
const match = /translate\(([^,]+),\s*([^)]+)\)/.exec(transform);
|
|
return { x: +match[1], y: +match[2] };
|
|
}
|
|
// Detect if two labels overlap
|
|
isOverlapping(recta, rectb) {
|
|
const ol_y = (recta.bottom >= rectb.top) && (recta.top <= rectb.bottom);
|
|
const ol_top = (recta.bottom > rectb.bottom) && ol_y;
|
|
const ol_bottom = (recta.top < rectb.top) && ol_y;
|
|
const ol_x = (recta.right >= rectb.left) && (recta.left <= rectb.right);
|
|
const ol_left = (recta.right > rectb.right) && ol_x;
|
|
const ol_right = (recta.left < rectb.left) && ol_x;
|
|
const overlapping = ol_y && ol_x;
|
|
const result = { overlapping: overlapping, top: ol_top, bottom: ol_bottom, left: ol_left, right: ol_right };
|
|
return result;
|
|
}
|
|
createClusteredLabel(group) {
|
|
// Gather merged text and coordinates
|
|
const names = group.map(el => el.querySelector("text").getAttribute("feature-name"));
|
|
const coords = group.map(el => {
|
|
const latlon = this.getElementLatLon(el);
|
|
return { lat: latlon.lat, lon: latlon.lon };
|
|
});
|
|
// Calculate the new position
|
|
const avgLat = coords.reduce((sum, c) => sum + c.lat, 0) / coords.length;
|
|
const avgLon = Math.min(...coords.map(c => c.lon));
|
|
// Create a new label with the combined text and append it to the svg elements.
|
|
const main = d3.select(group[0]);
|
|
const mergedLabel = {
|
|
type: "Feature",
|
|
properties: {
|
|
name: names.join('\n')
|
|
},
|
|
geometry: {
|
|
type: "Point",
|
|
coordinates: [avgLon, avgLat]
|
|
}
|
|
};
|
|
this.appendLabels(mergedLabel, main);
|
|
// Removes the labels in the group
|
|
for (let i = 1; i < group.length; i++) {
|
|
group[i].remove();
|
|
}
|
|
}
|
|
getOverlapZone(el) {
|
|
const overlappingOffset = 8;
|
|
const transEl = this.getElementTransform(el);
|
|
return { top: transEl.y - overlappingOffset, bottom: transEl.y + overlappingOffset, left: transEl.x - overlappingOffset, right: transEl.x + overlappingOffset };
|
|
}
|
|
getOverlappingZoneLabels(el) {
|
|
let pos = this.getElementTransform(el);
|
|
const anchor = el.getAttribute("label-anchor");
|
|
const rect = d3.select(el).select("rect");
|
|
const boxHeight = +rect.attr("height");
|
|
let offsetWidth = +rect.attr("width");
|
|
return { top: pos.y, bottom: pos.y + boxHeight, left: pos.x, right: pos.x + offsetWidth };
|
|
}
|
|
// Repositions the final groups
|
|
setFinalPositioning() {
|
|
// Sort by latitude
|
|
let sortedLabels = d3.selectAll("g.labelclone").nodes()
|
|
.sort((a, b) => {
|
|
let lonA = +a.getAttribute("data-lon");
|
|
let lonB = +a.getAttribute("data-lon");
|
|
let result = d3.ascending(lonA, lonB);
|
|
return result;
|
|
});
|
|
d3.selectAll("g.labelclone").each((_, i, nodes) => {
|
|
let translateVal = this.getOffsetPosition(i, nodes);
|
|
nodes[i].setAttribute("transform", translateVal);
|
|
});
|
|
}
|
|
getOffsetPosition(index, nodes) {
|
|
const el = nodes[index];
|
|
const pos = this.getElementTransform(el);
|
|
let markerHeight = 22;
|
|
let markerWidth = 14;
|
|
const boxHeight = +el.getAttribute("data-bbox-height");
|
|
const boxWidth = +el.getAttribute("data-bbox-width");
|
|
let anchor = el.getAttribute("label-anchor");
|
|
// Check if there is an overlapping with the marker and the label.
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
if (i !== index) {
|
|
let recta = this.getOverlappingZoneLabels(nodes[index]);
|
|
let rectb = this.getOverlappingZoneLabels(nodes[i]);
|
|
const ol_result = this.isOverlapping(recta, rectb);
|
|
if (ol_result.overlapping) {
|
|
console.log("index", index, "recta", recta, nodes[i]);
|
|
console.log("i", i, "rectb", rectb, nodes[index]);
|
|
console.log("overlapping", ol_result);
|
|
}
|
|
}
|
|
}
|
|
// Top
|
|
if (anchor === "top") {
|
|
return `translate(${pos.x}, ${pos.y - boxHeight})`;
|
|
}
|
|
else if (anchor === "bottom") {
|
|
return `translate(${pos.x}, ${pos.y + markerHeight})`;
|
|
}
|
|
else if (anchor === "left") {
|
|
return `translate(${pos.x - (boxWidth / 2 + markerWidth)}, ${pos.y})`;
|
|
}
|
|
else { // right
|
|
return `translate(${pos.x + (boxWidth / 2 + markerWidth)}, ${pos.y})`;
|
|
}
|
|
}
|
|
}
|
|
//# sourceMappingURL=MapViewLabelController.js.map
|