195 lines
8.3 KiB
JavaScript
195 lines
8.3 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-height", bbox.height);
|
|
}
|
|
// Update d3 shapes' positions to the map's current state
|
|
updateLabelsPosition() {
|
|
if (this.mapLabels !== null && this.mapLabels !== undefined) {
|
|
const lblVerticalOffset = 4;
|
|
// 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");
|
|
d3.selectAll("g.label, g.labelclone")
|
|
.attr("transform", (_, i, nodes) => {
|
|
const lon = Number(nodes[i].getAttribute("data-lon"));
|
|
const lat = Number(nodes[i].getAttribute("data-lat"));
|
|
const p = this.projectCoordinatesToPosition([lon, lat]);
|
|
const translateVal = `translate(${p.x}, ${p.y + lblVerticalOffset})`;
|
|
console.log(translateVal);
|
|
return translateVal;
|
|
});
|
|
// Check if the label overlaps and if it does create a grouup with the overlaping labels.
|
|
const labelNodes = d3.selectAll("g.labelclone").nodes();
|
|
const labelGroups = [];
|
|
for (let i = 0; i < labelNodes.length; i++) {
|
|
const lNode = labelNodes[i];
|
|
let groupFound = false;
|
|
for (let group of labelGroups) {
|
|
for (let j = 0; j < group.length; j++) {
|
|
if (this.isOverlapping(lNode, group[j])) {
|
|
group.push(lNode);
|
|
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([lNode]);
|
|
}
|
|
}
|
|
// Step 2: Merge labels in overlapping groups
|
|
for (const group of labelGroups) {
|
|
if (group.length === 1) {
|
|
continue;
|
|
}
|
|
// Gather merged text and coordinates
|
|
const names = group.map(el => el.querySelector("text").getAttribute("feature-name"));
|
|
const coords = group.map(el => {
|
|
const transform = el.getAttribute("transform");
|
|
const match = /translate\(([^,]+),\s*([^)]+)\)/.exec(transform);
|
|
return { x: +match[1], y: +match[2] };
|
|
});
|
|
// Calculate the new position
|
|
const avgX = coords.reduce((sum, c) => sum + c.x, 0) / coords.length;
|
|
const avgY = Math.min(...coords.map(c => c.y));
|
|
// 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: [avgX, avgY]
|
|
}
|
|
};
|
|
this.appendLabels(mergedLabel, main);
|
|
// Hide others
|
|
for (let i = 1; i < group.length; i++) {
|
|
group[i].style.display = "none";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Project GeoJSON coordinate to the map's current state
|
|
projectCoordinatesToPosition(c) {
|
|
return map.project(new mapboxgl.LngLat(+c[0], +c[1]));
|
|
}
|
|
// Detect if two labels overlap
|
|
isOverlapping(a, b) {
|
|
const recta = a.getBoundingClientRect();
|
|
const rectb = b.getBoundingClientRect();
|
|
const aright_lt_bleft = recta.right < rectb.left;
|
|
const abottom_lt_btop = recta.bottom < rectb.top;
|
|
const aleft_gt_bright = recta.left > rectb.right;
|
|
const atop_gt_bbottom = recta.top > rectb.bottom;
|
|
const overlaping = !(aright_lt_bleft || aleft_gt_bright || abottom_lt_btop || atop_gt_bbottom);
|
|
return overlaping;
|
|
}
|
|
}
|
|
//# sourceMappingURL=MapViewLabelController.js.map
|