Almost there. Adds typescript

This commit is contained in:
2025-08-27 07:38:58 -06:00
parent 298faa23d6
commit efdeaec00f
5 changed files with 526 additions and 281 deletions

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
<link href="https://api.tiles.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.tiles.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style media="screen">
body { margin:0; padding:0; }
@@ -26,285 +26,6 @@
<body>
<div id="map">
</div>
<script>
//////////////////
// Mapbox stuff
//////////////////
// Set-up map
mapboxgl.accessToken = 'pk.eyJ1Ijoiam9yZGl0b3N0IiwiYSI6ImQtcVkyclEifQ.vwKrOGZoZSj3N-9MB6FF_A';
var map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/outdoors-v9',
zoom: 11.5,
center: [13.4426, 52.5100],
});
//////////////////////////
// Mapbox+D3 Connection
//////////////////////////
// Get Mapbox map canvas container
var canvas = map.getCanvasContainer();
// Overlay d3 on the map
var svg = d3.select(canvas).append("svg");
// Load map and dataset
map.on('load', function () {
d3.json("data/berlin-parks-new.json", function(err, data) {
drawData(data);
});
});
// Project GeoJSON coordinate to the map's current state
function project(d) {
return map.project(new mapboxgl.LngLat(+d[0], +d[1]));
}
//////////////
// D3 stuff
//////////////
// Draw GeoJSON data with d3
var circles;
var labels;
function drawData(data) {
// Add circles
circles = svg.selectAll("circle")
.data(data.features)
.enter()
.append("circle")
.attr("r", 8);
/*.append("title") // append a title for tooltip
.text(function(d) { return d.properties.name; });*/
// Add labels
/*labels = svg.selectAll("text")
.data(data.features)
.enter()
.append("text")
.text(function(d) { return d.properties.name; }) // or any other property
.attr("font-size", "12px")
.attr("fill", "black")
.attr("color", "white");*/
lablels = getMarkerLabels(data);
// Call the update function
update();
// Update on map interaction
map.on("viewreset", update);
map.on("move", update);
map.on("moveend", update);
}
function getMarkerLabels(data){
// Add labels with background
const maxWidth = 100;
console.log("In markerlabels");
const markerLabels = svg.selectAll("g.label")
.data(data.features)
.enter()
.append("g")
.attr("class", "label")
.each(function(d) {
const g = d3.select(this);
const name = d.properties.name;
// Append the text
const text = g.append("text")
.attr("feature-name", d.properties.name)
.attr("font-size", "12px")
.attr("fill", "black")
.attr("text-anchor", "middle")
.attr("x", 0);
// Manually wrap text
const words = name.split(/\s+/);
let line = [];
let lineNumber = 0;
const lineHeight = 1.1; // ems
const x = 0;
const y = 0;
let tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", `${lineNumber * lineHeight}em`);
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) {
line.pop();
tspan.text(line.join(" "));
line = [words[i]];
tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", `${++lineNumber * lineHeight}em`)
.text(words[i]);
}
}
// Measure the text
const bbox = text.node().getBBox();
// Move both text and rect left by half the text width to center
text.attr("x", -bbox.width / 2);
// Append rect behind text
g.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);
// Save bbox dimensions for use in `update()`
//g.attr("data-bbox-width", bbox.width);
g.attr("data-bbox-height", bbox.height);
});
console.log("EIT");
console.log(markerLabels);
return markerLabels;
}
// Update d3 shapes' positions to the map's current state
function update() {
const lblVerticalOffset = 4;
//console.log("update");
circles.attr("cx", function(d) { return project(d.geometry.coordinates).x })
.attr("cy", function(d) { return project(d.geometry.coordinates).y });
/*labels.attr("x", function(d) { return project(d.geometry.coordinates).x - 8}) // offset to right of circle
.attr("y", function(d) { return project(d.geometry.coordinates).y - 20 }); // vertically center text*/
labels
.attr("transform", function(d) {
const p = project(d.geometry.coordinates);
const dy = -(+this.getAttribute("data-bbox-height") + lblVerticalOffset)
return `translate(${p.x}, ${p.y + dy})`;
});
//console.log(map.getZoom());
const labelNodes = labels.nodes();
const mergedGroups = [];
for(let i = 0; i < labelNodes.length; i++){
const a = labelNodes[i];
let groupFound = false;
for (let group of mergedGroups) {
for (let j = 0; j < group.length; j++) {
if (isOverlapping(a, group[j])) {
console.log(a);
group.push(a);
groupFound = true;
break;
}
}
if (groupFound) break;
}
if (!groupFound) {
mergedGroups.push([a]);
}
}
// Step 2: Merge labels in overlapping groups
for (const group of mergedGroups) {
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] };
});
// Average position
const avgX = coords.reduce((sum, c) => sum + c.x, 0) / coords.length;
const avgY = coords.reduce((sum, c) => sum + c.y, 0) / coords.length;
// Keep only the first label, update its text
const main = group[0];
const textEl = main.querySelector("text");
// Clear previous tspans or text
textEl.innerHTML = "";
// Create a new <tspan> per name, each on a new line
names.forEach((name, i) => {
const tspan = document.createElementNS("http://www.w3.org/2000/svg", "tspan");
tspan.setAttribute("x", 0);
tspan.setAttribute("dy", i === 0 ? "0" : "1.2em"); // first line dy=0, next lines shift down
tspan.textContent = name;
textEl.appendChild(tspan);
});
main.setAttribute("transform", `translate(${avgX}, ${avgY})`);
// 3. Resize and reposition the <rect> background
const textBBox = textEl.getBBox();
const g = main; // the parent <g> element
const rect = g.querySelector("rect");
if (rect) {
rect.setAttribute("x", textBBox.x - 4);
rect.setAttribute("y", textBBox.y - 2);
rect.setAttribute("width", textBBox.width + 8);
rect.setAttribute("height", textBBox.height + 4);
}
// Optional: Update bbox height for offsetting in update()
g.setAttribute("data-bbox-height", textBBox.height);
// Hide others
for (let i = 1; i < group.length; i++) {
group[i].style.display = "none";
}
}
}
function 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);
if(overlaping){
const aName = a.querySelector("text").getAttribute("feature-name");
const bName = b.querySelector("text").getAttribute("feature-name");
console.log(`A: ${aName} (${(+recta.right).toFixed(4)}, ${recta.left}), B: ${bName}`);
console.log(aright_lt_bleft, abottom_lt_btop, aleft_gt_bright, atop_gt_bbottom);
}
return overlaping;
}
</script>
<script src="./02-script.js"></script>
</body>
</html>

224
02-script.js Normal file
View File

@@ -0,0 +1,224 @@
// #region Map Section
mapboxgl.accessToken = 'pk.eyJ1Ijoiam9yZGl0b3N0IiwiYSI6ImQtcVkyclEifQ.vwKrOGZoZSj3N-9MB6FF_A';
var map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/outdoors-v9',
zoom: 11.5,
center: [13.4426, 52.5100],
});
// Get Mapbox map canvas container
var canvas = map.getCanvasContainer();
// Overlay d3 on the map
var svg = d3.select(canvas).append("svg");
// Load map and dataset
map.on('load', function () {
d3.json("data/berlin-parks-new.json")
.then(function (data) {
drawData(data);
})
.catch(function (error) {
console.error("Error loading data", error);
});
});
// Project GeoJSON coordinate to the map's current state
function project(d) {
return map.project(new mapboxgl.LngLat(+d[0], +d[1]));
}
// #endregion
// Draw GeoJSON data with d3
var circles;
var labels;
function drawData(data) {
// Add circles
circles = svg.selectAll("circle")
.data(data.features)
.enter()
.append("circle")
.attr("r", 8);
labels = svg.selectAll("g.label")
.data(data.features)
.enter()
.append("g")
.attr("class", "label")
.each(function (d) {
var g = d3.select(this);
appendLabel(d, g);
});
// Call the update function
update();
// Update on map interaction
map.on("viewreset", update);
map.on("move", update);
map.on("moveend", update);
}
// Add labels with background
function appendLabel(d, g) {
var _a, _b;
var maxWidth = 100;
var name = (_b = (_a = d.properties) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : "";
var _c = d.geometry.coordinates, lon = _c[0], lat = _c[1];
g.attr("data-lon", lon)
.attr("data-lat", lat);
g.select("text").remove();
g.select("rect").remove();
// Append the text
var text = g.append("text")
.attr("feature-name", name)
.attr("font-size", "12px")
.attr("fill", "black")
.attr("text-anchor", "middle")
.attr("x", 0);
// Manually wrap text
var words = name.split(/\s+/);
var line = [];
var lineNumber = 0;
var lineHeight = 1.1; // ems
var x = 0;
var y = 0;
var tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", "".concat(lineNumber * lineHeight, "em"));
for (var 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) {
line.pop();
tspan.text(line.join(" "));
line = [words[i]];
tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", "".concat(++lineNumber * lineHeight, "em"))
.text(words[i]);
}
}
// Measure the text
var bbox = text.node().getBBox();
// Move both text and rect left by half the text width to center
text.attr("x", -bbox.width / 2);
// Append rect behind text
g.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);
g.attr("data-bbox-height", bbox.height);
}
// Update d3 shapes' positions to the map's current state
function update() {
if (labels !== null) {
// #region
var lblVerticalOffset_1 = 4;
circles.attr("cx", function (d) {
return project(d.geometry.coordinates).x;
})
.attr("cy", function (d) {
return project(d.geometry.coordinates).y;
});
// Make clones of original labels.
d3.selectAll("g.labelclone").remove();
var labelNodes = labels.each(function (_, i) {
var clone = this.cloneNode(true);
clone.setAttribute("class", "labelclone");
clone.setAttribute("data-elementid", i.toString());
clone.removeAttribute("style");
d3.select(this.parentNode).append(function () { return clone; });
}).nodes();
d3.selectAll("g.label").style("display", "none");
d3.selectAll("g.label, g.labelclone")
.attr("transform", function (d) {
var lon = Number(this.getAttribute("data-lon"));
var lat = Number(this.getAttribute("data-lat"));
var p = project([lon, lat]);
var dy = -(Number(this.getAttribute("data-bbox-height")) + lblVerticalOffset_1);
return "translate(".concat(p.x, ", ").concat(p.y + dy, ")");
});
console.log(labelNodes);
//let labelNodes = d3.select("g.labelclones").nodes;
var graph = new Map();
for (var i = 0; i < labelNodes.length; i++) {
for (var j = i + 1; j < labelNodes.length; j++) {
if (isOverlapping(labelNodes[i], labelNodes[j])) {
console.log("A:", labelNodes[i], labelNodes[i].getClientRects(), labelNodes[i].getBoundingClientRect());
console.log("B: ", labelNodes[j], labelNodes[j].getClientRects(), labelNodes[j].getBoundingClientRect());
console.log("Overlapping!");
}
}
}
return;
var labelGroups = [];
// #endregion
console.log("Nodes Length: ", labelNodes.length);
for (var i = 0; i < labelNodes.length; i++) {
var lNode = labelNodes[i];
var groupFound = false;
console.log("FeatureName: ", lNode.getAttribute("feature-name"), labelGroups);
for (var _i = 0, labelGroups_1 = labelGroups; _i < labelGroups_1.length; _i++) {
var group = labelGroups_1[_i];
console.log("A");
for (var j = 0; j < group.length; j++) {
console.log("B");
if (isOverlapping(lNode, group[j])) {
console.log(lNode);
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 (var _a = 0, labelGroups_2 = labelGroups; _a < labelGroups_2.length; _a++) {
var group = labelGroups_2[_a];
if (group.length === 1) {
continue;
}
// Gather merged text and coordinates
var names = group.map(function (el) { return el.querySelector("text").getAttribute("feature-name"); });
var coords = group.map(function (el) {
var transform = el.getAttribute("transform");
var match = /translate\(([^,]+),\s*([^)]+)\)/.exec(transform);
return { x: +match[1], y: +match[2] };
});
// Average position
var avgX = coords.reduce(function (sum, c) { return sum + c.x; }, 0) / coords.length;
var avgY = coords.reduce(function (sum, c) { return sum + c.y; }, 0) / coords.length;
// Keep only the first label, update its text
var main = d3.select(group[0]);
appendLabel(names.join('\n'), main);
// Hide others
for (var i = 1; i < group.length; i++) {
group[i].style.display = "none";
}
}
}
}
function isOverlapping(a, b) {
var recta = a.getBoundingClientRect();
var rectb = b.getBoundingClientRect();
var aright_lt_bleft = recta.right < rectb.left;
var abottom_lt_btop = recta.bottom < rectb.top;
var aleft_gt_bright = recta.left > rectb.right;
var atop_gt_bbottom = recta.top > rectb.bottom;
var overlaping = !(aright_lt_bleft || aleft_gt_bright || abottom_lt_btop || atop_gt_bbottom);
// if (overlaping) {
// const aName = a.querySelector("text")!.getAttribute("feature-name");
// const bName = b.querySelector("text")!.getAttribute("feature-name");
// console.log(`A: ${aName} (${(+recta.right).toFixed(4)}, ${recta.left}), B: ${bName}`);
// console.log(aright_lt_bleft, abottom_lt_btop, aleft_gt_bright, atop_gt_bbottom);
// }
return overlaping;
}

280
02-script.ts Normal file
View File

@@ -0,0 +1,280 @@
// #region Map Section
mapboxgl.accessToken = 'pk.eyJ1Ijoiam9yZGl0b3N0IiwiYSI6ImQtcVkyclEifQ.vwKrOGZoZSj3N-9MB6FF_A';
var map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/outdoors-v9',
zoom: 11.5,
center: [13.4426, 52.5100],
});
// Get Mapbox map canvas container
var canvas = map.getCanvasContainer();
// Overlay d3 on the map
var svg = d3.select(canvas).append("svg");
// Load map and dataset
map.on('load', function () {
d3.json("data/berlin-parks-new.json")
.then(function (data) {
drawData(data as GeoJSON.FeatureCollection);
})
.catch(function(error){
console.error("Error loading data", error);
});
});
// Project GeoJSON coordinate to the map's current state
function project(d : number[]) {
return map.project(new mapboxgl.LngLat(+d[0], +d[1]));
}
// #endregion
// Draw GeoJSON data with d3
var circles : d3.Selection<
SVGCircleElement,
GeoJSON.Feature<GeoJSON.Geometry, GeoJSON.GeoJsonProperties>,
SVGSVGElement,
unknown
>;
let labels : d3.Selection<
SVGGElement,
GeoJSON.Feature<GeoJSON.Geometry, GeoJSON.GeoJsonProperties>,
SVGSVGElement,
unknown
>;
function drawData(data : GeoJSON.FeatureCollection) {
// Add circles
circles = svg.selectAll("circle")
.data(data.features)
.enter()
.append("circle")
.attr("r", 8);
labels = svg.selectAll("g.label")
.data(data.features)
.enter()
.append("g")
.attr("class", "label")
.each(function (d) {
const g = d3.select(this);
appendLabel(d, g);
});
// Call the update function
update();
// Update on map interaction
map.on("viewreset", update);
map.on("move", update);
map.on("moveend", update);
}
// Add labels with background
function appendLabel(d : GeoJSON.Feature, g : d3.Selection<SVGGElement, unknown, null, undefined>){
const maxWidth = 100;
const name = d.properties?.name ?? "";
const [lon, lat] = (d.geometry as GeoJSON.Point).coordinates;
g.attr("data-lon", lon)
.attr("data-lat", lat);
g.select("text").remove();
g.select("rect").remove();
// Append the text
const text = g.append("text")
.attr("feature-name", name)
.attr("font-size", "12px")
.attr("fill", "black")
.attr("text-anchor", "middle")
.attr("x", 0);
// Manually wrap text
const words = name.split(/\s+/);
let line = [];
let lineNumber = 0;
const lineHeight = 1.1; // ems
const x = 0;
const y = 0;
let tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", `${lineNumber * lineHeight}em`);
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) {
line.pop();
tspan.text(line.join(" "));
line = [words[i]];
tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", `${++lineNumber * lineHeight}em`)
.text(words[i]);
}
}
// Measure the text
const bbox = text.node()!.getBBox();
// Move both text and rect left by half the text width to center
text.attr("x", -bbox.width / 2);
// Append rect behind text
g.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);
g.attr("data-bbox-height", bbox.height);
}
// Update d3 shapes' positions to the map's current state
function update() {
if (labels !== null) {
// #region
const lblVerticalOffset = 4;
circles.attr("cx", function (d) {
return project((d.geometry as GeoJSON.Point).coordinates).x
})
.attr("cy", function (d) {
return project((d.geometry as GeoJSON.Point).coordinates).y
});
// Make clones of original labels.
d3.selectAll("g.labelclone").remove();
const labelNodes = labels.each(function(_,i){
const clone = this.cloneNode(true) as Element;
clone.setAttribute("class", "labelclone");
clone.setAttribute("data-elementid", i.toString());
clone.removeAttribute("style");
d3.select(this.parentNode).append(() => clone);
}).nodes();
d3.selectAll("g.label").style("display", "none");
d3.selectAll("g.label, g.labelclone")
.attr("transform", function (d) {
const lon = Number(this.getAttribute("data-lon"));
const lat = Number(this.getAttribute("data-lat"));
const p = project([lon, lat]);
const dy = -(Number(this.getAttribute("data-bbox-height"))! + lblVerticalOffset)
return `translate(${p.x}, ${p.y + dy})`;
});
console.log(labelNodes);
//let labelNodes = d3.select("g.labelclones").nodes;
const graph = new Map<string, Set<string>>();
for(let i = 0; i < labelNodes.length; i++){
for(let j = i + 1; j < labelNodes.length; j++){
if(isOverlapping(labelNodes[i], labelNodes[j])){
console.log("A:", labelNodes[i], labelNodes[i].getClientRects(), labelNodes[i].getBoundingClientRect());
console.log("B: ", labelNodes[j], labelNodes[j].getClientRects(), labelNodes[j].getBoundingClientRect());
console.log("Overlapping!");
}
}
}
return;
const labelGroups = [];
// #endregion
console.log("Nodes Length: ", labelNodes.length);
for (let i = 0; i < labelNodes.length; i++) {
const lNode = labelNodes[i];
let groupFound = false;
console.log("FeatureName: ", lNode.getAttribute("feature-name"), labelGroups);
for (let group of labelGroups) {
console.log("A");
for (let j = 0; j < group.length; j++) {
console.log("B");
if (isOverlapping(lNode, group[j])) {
console.log(lNode);
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] };
});
// Average position
const avgX = coords.reduce((sum, c) => sum + c.x, 0) / coords.length;
const avgY = coords.reduce((sum, c) => sum + c.y, 0) / coords.length;
// Keep only the first label, update its text
const main = d3.select(group[0]);
appendLabel(names.join('\n'), main);
// Hide others
for (let i = 1; i < group.length; i++) {
group[i].style.display = "none";
}
}
}
}
function isOverlapping(a : SVGElement, b : SVGElement) {
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);
// if (overlaping) {
// const aName = a.querySelector("text")!.getAttribute("feature-name");
// const bName = b.querySelector("text")!.getAttribute("feature-name");
// console.log(`A: ${aName} (${(+recta.right).toFixed(4)}, ${recta.left}), B: ${bName}`);
// console.log(aright_lt_bleft, abottom_lt_btop, aleft_gt_bright, atop_gt_bbottom);
// }
return overlaping;
}

7
package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"devDependencies": {
"@types/d3": "^7.4.3",
"@types/geojson": "^7946.0.16",
"@types/mapbox-gl": "^3.4.1"
}
}

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"outDir": "./dist",
"target": "es6",
"module": "commonjs",
"strict": true,
"skipLibCheck": true
},
"include": [
"02-script.ts"
],
"exclude": ["../node_modules", "node_modules"]
}