This commit is contained in:
2025-11-19 07:59:59 -06:00
parent df14ce8ea0
commit d6465459f5
4 changed files with 231 additions and 93 deletions

View File

@@ -9,7 +9,7 @@ mapboxgl.accessToken = 'pk.eyJ1Ijoiam9yZGl0b3N0IiwiYSI6ImQtcVkyclEifQ.vwKrOGZoZS
let map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/outdoors-v9',
zoom: 12
zoom: 14
});
let mapViewLabelController = new MapViewLabelController(map);
// Get Mapbox map canvas container
@@ -30,7 +30,6 @@ map.on('load', function () {
}
]
};
console.log(trackData);
map.addSource('flight-track', {
type: 'geojson',
data: trackData
@@ -76,7 +75,6 @@ function drawData(data) {
map.on("moveend", update);
}
function update() {
console.log("update");
circles.attr("cx", function (d) {
return mapViewLabelController.projectCoordinatesToPosition(d.geometry.coordinates).x;
})

View File

@@ -11,7 +11,7 @@ mapboxgl.accessToken = 'pk.eyJ1Ijoiam9yZGl0b3N0IiwiYSI6ImQtcVkyclEifQ.vwKrOGZoZS
let map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/outdoors-v9',
zoom: 12
zoom: 14
});
let mapViewLabelController = new MapViewLabelController(map);
@@ -37,8 +37,6 @@ map.on('load', function () {
]
};
console.log(trackData);
map.addSource('flight-track', {
type: 'geojson',
data: trackData
@@ -100,9 +98,6 @@ function drawData(data : GeoJSON.FeatureCollection) {
}
function update(){
console.log("update");
circles.attr("cx", function(d){
return mapViewLabelController.projectCoordinatesToPosition((d.geometry as GeoJSON.Point).coordinates).x;
})

View File

@@ -94,12 +94,13 @@ class MapViewLabelController {
.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) {
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) => {
@@ -110,86 +111,180 @@ class MapViewLabelController {
d3.select(nodes[i].parentNode).append(() => clone);
});
d3.selectAll("g.label").style("display", "none");
d3.selectAll("g.label, g.labelclone")
.attr("transform", (_, i, nodes) => {
// 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]);
const translateVal = `translate(${p.x}, ${p.y + lblVerticalOffset})`;
console.log(translateVal);
return translateVal;
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 label overlaps and if it does create a grouup with the overlaping labels.
const labelNodes = d3.selectAll("g.labelclone").nodes();
// Check if the marker overlaps and if it does create a group merging labels
const labelGroups = [];
for (let i = 0; i < labelNodes.length; i++) {
const lNode = labelNodes[i];
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++) {
if (this.isOverlapping(lNode, group[j])) {
group.push(lNode);
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)
if (groupFound) {
break;
}
}
// If any label group was found then create a new one with the label node
if (!groupFound) {
labelGroups.push([lNode]);
labelGroups.push([node]);
}
}
// Step 2: Merge labels in overlapping groups
});
// 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";
}
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(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;
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

View File

@@ -116,6 +116,7 @@ class MapViewLabelController {
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
@@ -135,12 +136,15 @@ class MapViewLabelController {
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){
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);
}
@@ -151,7 +155,6 @@ class MapViewLabelController {
// 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;
@@ -160,29 +163,39 @@ class MapViewLabelController {
for (let j = 0; j < group.length; j++) {
const recta = this.getOverlapZone(node);
const rectb = this.getOverlapZone(group[j]);
if (this.isOverlapping(recta, rectb)) {
group.push(node);
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){
labelGroups.push([node]);
if (groupFound){
break;
}
}
// If any label group was found then create a new one with the label node
if (!groupFound) {
labelGroups.push([lNode]);
labelGroups.push([node]);
}
}
});
// Step 2: Merge labels in overlapping groups
// Merge labels in overlapping groups
for (const group of labelGroups) {
if (group.length === 1) {
continue;
}
this.createClusteredLabel(group)
}
this.setFinalPositioning();
}
}
@@ -199,6 +212,7 @@ class MapViewLabelController {
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);
@@ -208,17 +222,23 @@ class MapViewLabelController {
// 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 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);
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;
return overlaping;
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 => {
@@ -240,26 +260,39 @@ class MapViewLabelController {
},
geometry: {
type: "Point",
coordinates: [avgLat, avgLon]
coordinates: [avgLon, avgLat]
}
};
this.appendLabels(mergedLabel, main);
// Hide others
// Removes the labels in the group
for (let i = 1; i < group.length; i++) {
group[i].remove();
}
}
getOverlapZone(el){
const overlappingOffset = 0;
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) => {
@@ -271,7 +304,7 @@ class MapViewLabelController {
});
d3.selectAll("g.labelclone").each( (_, i, nodes) => {
let translateVal = this.getOffsetPosition(i, node);
let translateVal = this.getOffsetPosition(i, nodes);
nodes[i].setAttribute("transform", translateVal);
});
@@ -282,22 +315,39 @@ class MapViewLabelController {
const pos = this.getElementTransform(el);
let markerHeight = 22;
let markerWidht = 14;
const boxHeight = +el.getAttribute("data-box-height");
const boxWidth = +el.getAttribute("data-box-width");
let markerWidth = 14;
const boxHeight = +el.getAttribute("data-bbox-height");
const boxWidth = +el.getAttribute("data-bbox-width");
let anchor = el.getAttribute("label-anchor");
// Up
return `translate(${pos.x}, ${pos.y - boxHeight})`;
// 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]);
/*
// Down
return `translate(${pos.x}, ${pos.y - markerHeight})`;
const ol_result = this.isOverlapping(recta, rectb);
// Left
return `translate(${pos.x - (boxWidth/2 + markerWidth)}, ${pos.y})`;
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);
}
}
}
// Right
return `translate(${pos.x + (boxWidth/2 + markerWidth)}, ${pos.y})`;
*/
// 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})`;
}
}
}