Files
mapbox_gl_d3_playground/MapViewLabelController.js

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