Files
mapbox_gl_d3_playground/MapViewLabelController.ts
2025-11-19 07:59:59 -06:00

354 lines
13 KiB
TypeScript

// 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 : GeoJSON.Feature, graphElem : d3.Selection<SVGGElement, unknown, null, undefined>){
const maxWidth = 100;
const name = geoData.properties?.name ?? "";
const [lon, lat] = (geoData.geometry as GeoJSON.Point).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) as Element;
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 : number[]) {
return map.project(new mapboxgl.LngLat(+c[0], +c[1]));
}
// Get the x-y properties in a svg group element
getElementTransform(el : Element){
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 : GeoJSON.Feature<GeoJSON.Point> = {
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})`;
}
}
}