Published
Edited
Jun 3, 2021
27 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
positions = ({
0: { name: "hidden", score: 0 },
1: { name: "top", score: 10 },
2: { name: "bottom", score: 7 },
3: { name: "right", score: 9 },
4: { name: "left", score: 4 },
5: { name: "inside", score: 20 * !!allow_inside, fill: "white" }
})
Insert cell
textMeasure = {
const w = new Map(),
ctx = DOM.context2d(1, 1);
ctx.font = font;
return function(text) {
if (!w.has(text)) {
const ms = ctx.measureText(text);
w.set(text, {
width: ms.width,
height: parseInt(font)
});
}
return w.get(text);
};
}
Insert cell
function dimensions(d) {
const ms = textMeasure(d.properties.name),
width = ms.width + 4,
height = ms.height + 3;
const x =
d.anchor === "end"
? d.lx - width
: d.anchor == "start"
? d.lx
: d.lx - width / 2;
const y =
d.baseline === "baseline" || d.baseline === "bottom"
? d.ly - height
: d.baseline == "hanging" || d.baseline === "top"
? d.ly
: d.ly - height / 2;

return { x, y, width, height };
}
Insert cell
function move_centers(cities, projection) {
cities.forEach(c => {
[c.x, c.y] = projection(c.geometry.coordinates);
c.r = pointRadius(c);
});

const force = d3
.forceSimulation()
.force("x", d3.forceX(d => d.x))
.force("y", d3.forceY(d => d.y))
.force("collide", d3.forceCollide().radius(d => 0.5 + d.r))
.nodes(cities)
.stop()
.tick(15);

return cities;
}
Insert cell
pointRadius = {
const scale = d3
.scaleSqrt()
.domain([0, 65])
.range([3, 30]);
return c => scale(+c.properties.count);
}
Insert cell
function make_map(cities) {
const context = DOM.context2d(w, h),
canvas = context.canvas,
path = d3.geoPath().context(context),
geopath = d3.geoPath(projection).context(context);

cities = move_centers(cities, projection);

const best = place_labels(cities);
cities = best.cities;

context.font = font;

context.fillStyle = "#eee";
context.beginPath();
geopath({ type: "Sphere" });
context.fill();

context.fillStyle = "#fff";
context.beginPath();
geopath(world);
context.fill();
context.strokeStyle = "#eee";
context.stroke();
context.fillStyle = "#000";

context.beginPath();
cities.forEach(c => {
path.pointRadius(pointRadius(c));
path({
type: "Point",
coordinates: [c.x, c.y]
});
});
context.fill();

if (debug) {
context.fillText(`${best.i} iterations (score: ${best.score | 0})`, 10, 10);
}

cities.forEach(c => {
if (c.label) {
context.textBaseline = c.baseline;
context.textAlign = c.anchor;
context.fillStyle = positions[c.pos].fill || "black";
context.fillText(c.label, c.lx, c.ly);
}

if (debug) {
if (!c.label) {
context.textBaseline = c.baseline;
context.textAlign = c.anchor;
context.fillStyle = "orange";
context.fillText(c.label || c.properties.name, c.lx, c.ly);
}

context.beginPath();
context.moveTo(c.dimensions.x, c.dimensions.y);
context.lineTo(c.dimensions.x + c.dimensions.width, c.dimensions.y);
context.lineTo(
c.dimensions.x + c.dimensions.width,
c.dimensions.y + c.dimensions.height
);
context.lineTo(c.dimensions.x, c.dimensions.y + c.dimensions.height);
context.lineTo(c.dimensions.x, c.dimensions.y);
context.strokeStyle = c.label ? "green" : "orange";
context.stroke();
}
});

return canvas;
}
Insert cell
function prepare(c, pos) {
c.label = c.properties.name;

switch (pos) {
case 1:
c.anchor = "center";
c.baseline = "bottom";
c.lx = c.x;
c.ly = c.y - (c.r + 1);
break;

case 2:
c.anchor = "center";
c.baseline = "top";
c.lx = c.x;
c.ly = c.y + (c.r + 1);
break;

case 3:
c.anchor = "start";
c.baseline = "middle";
c.lx = c.x + (c.r + 1);
c.ly = c.y;
break;

case 4:
c.anchor = "end";
c.baseline = "middle";
c.lx = c.x - (c.r + 1);
c.ly = c.y;
break;

case 5:
c.anchor = "center";
c.baseline = "middle";
c.lx = c.x;
c.ly = c.y;
break;

default:
c.anchor = "center";
c.baseline = "middle";
c.lx = c.x;
c.ly = c.y;
c.label = "";
break;
}

c.dimensions = dimensions(c);
c.pos = pos;
return c;
}
Insert cell
function test_positions(pos, cities) {
const labelled = [];

let score = 0;

cities.forEach((c, i) => {
c = prepare(c, pos[i]);

if (
labelled.some(a => a.pos > 0 && intersect(a.dimensions, c.dimensions))
) {
c.label = "";
c.pos = pos[i] = 0;
}

score += c.r ** 2 * positions[c.pos].score;
labelled.push(c);
});
return { score, pos };
}
Insert cell
function place_labels(cities) {
cities = cities.sort((a, b) => d3.descending(+a.count, +b.count));
cities.forEach(c => {
c.block = {
x: c.x - c.r,
y: c.y - c.r * 0.9,
width: 2 * c.r,
height: 2 * c.r * 0.9
};
});

const borders = [
{ x: 0, width: 0, y: 0, height: w },
{ x: 0, width: w, y: 0, height: 0 },
{ x: w, width: 0, y: 0, height: w },
{ x: 0, width: w, y: w, height: 0 }
];

const possibilities = cities.map(c =>
Object.keys(positions).filter(p => {
p = +p;
if (!p) return true; // "hidden" is always a possibility
c = prepare(c, p);

if (p === 5) {
console.warn(c);
return c.dimensions.width < 2 * c.r;
}

return !(
borders.some(a => intersect(a, c.dimensions)) ||
cities.some(a => intersect(a.block, c.dimensions))
);
})
);

let best = {
score: 0,
pos: Uint8Array.from({ length: cities.length })
};
for (let i = 0; i < 150 * cities.length; i++) {
const arrangement = best.pos.map((p, i) =>
Math.random() < 0.3
? possibilities[i][(Math.random() * possibilities[i].length) | 0]
: p
);
const test = test_positions(arrangement, cities);
if (test.score > best.score) {
best = test;
best.i = i;
}
}

best.cities = cities.map((c, i) => prepare(c, best.pos[i]));
return best;
}
Insert cell
function intersect(a, b) {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
);
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
projection = d3
.geoAirocean()
.scale(width / 20)
.translate([(w / 2) * 0.95, h / 2])
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more