Public
Edited
Feb 15, 2023
Importers
40 stars
Insert cell
Insert cell
edited_geo.features
Insert cell
viewof edited_geo = editor.mapView()
Insert cell
editor.buttonView()
Insert cell
Insert cell
editor.tableView(["color", "admin", "pop_est"])
Insert cell
editor.propertiesView()
Insert cell
editor.propertyView("admin")
Insert cell
editor.propertyView("pop_est")
Insert cell
editor.colorPropertyView()
Insert cell
editor.jsonView()
Insert cell
editor = geoEditor({
features: geo,
projection: projection
})
Insert cell
Insert cell
Insert cell
function geoEditor({ features, container, render, projection, options } = {}) {
features = features || { type: "FeatureCollection", features: [] };
projection = projection || d3.geoMercator();
options = options || { zoom: true };

const dispatch = d3.dispatch("update", "data", "zoomTo"),
editor = {
features,
container,
render,
projection,
options,
dispatch
};

editor.mapView = () => mapView(editor);
editor.buttonView = () => buttonView(editor);
editor.propertiesView = () => propertiesView(editor);
editor.propertyView = property => propertyView(editor, property);
editor.colorPropertyView = () => colorPropertyView(editor);
editor.jsonView = () => jsonView(editor);
editor.tableView = properties => tableView(editor, properties);

editor.selected = () => features.features.filter(d => d.selected);

// debounce data => update
dispatch.on(
"data",
debounce(message => {
dispatch.call("update", null, message);
}, 400)
);

return editor;
}
Insert cell
geo = {
const geo = JSON.parse(JSON.stringify(countries));

geo.features.forEach(d => (d.properties.color = d.properties.fill));
return geo;

/*({
type: "FeatureCollection",
features: []
})*/
}
Insert cell
function defaultRenderer(canvas, features, projection) {
const context = canvas.getContext("2d"),
width = canvas.width,
height = canvas.height;

const path = d3.geoPath(projection).context(context);
context.clearRect(0, 0, width, height);
context.strokeStyle = "#000";

context.beginPath();
path(d3.geoGraticule10());
context.lineWidth = 0.25;
context.stroke();

context.beginPath();
path(world);
context.fillStyle = "rgba(255,255,255,0.5)";
context.fill();
context.lineWidth = 0.5;
context.stroke();

context.beginPath();
path({ type: "Sphere" });
context.lineWidth = 2;
context.stroke();
}
Insert cell
function subject(elements, projection, maxDistance = 10) {
return function(evt) {
if (evt === undefined) evt = d3.event;

let subject = null;
let distance = maxDistance;

for (const pt of geoPoints(elements, projection)) {
const [x, y] = pt.xy;
let d = Math.hypot(evt.x - x, evt.y - y);
if (d < distance) {
distance = d;
subject = pt;
pt.x = x;
pt.y = y;
}
}

return subject;
};
}
Insert cell
function geoPoints(elements, projection) {
const data = [];
let p0;
for (const p of getPoints(
elements.filter(d => d.geometry.type === "Point" || d.selected)
)) {
p.xy = projection(p);
if (
p.xy[0] >= 0 &&
p.xy[0] <= width &&
p.xy[1] >= 0 &&
p.xy[1] <= height &&
(!p.i || !p0 || euclidian(p.xy, p0.xy) > 8 * (1 + !!p.midpoint))
) {
data.push(p);
p0 = p;
}
}
return data;

function* getPoints(elements) {
for (const elt of elements) {
const coords = elt.geometry.coordinates;
switch (elt.geometry.type) {
case "Point":
yield Object.assign(coords, { elt });
break;
case "MultiPoint":
for (const p of coords) yield Object.assign(p, { elt });
break;
case "LineString":
yield* line(coords, elt);
break;
case "Polygon":
for (const ring of elt.geometry.coordinates)
yield* line(ring, elt, true);
break;
case "MultiLineString":
for (const ring of elt.geometry.coordinates) yield* line(ring, elt);
break;
case "MultiPolygon":
for (const polygon of elt.geometry.coordinates)
for (const ring of polygon) yield* line(ring, elt, true);
break;
}
}
}

function* line(coords, elt, closed) {
let pt0,
i = 0;
for (const pt of coords) {
if (i) {
const midpoint = d3.geoInterpolate(pt0, pt)(.5);
yield Object.assign(midpoint, {
elt,
coords,
i,
midpoint: true
});
}
if (!closed || i < coords.length - 1) {
yield Object.assign(pt, { elt, coords, i, closed });
pt0 = pt;
i++;
}
}
}
}
Insert cell
function colorPropertyView(editor) {
const { features, dispatch } = editor;

function getTopColor() {
const selected = editor.selected()[0];
return selected ? d3.rgb(selected.properties.color).hex() : "";
}

const form = html`<form><input type=color>`;

const el = form.elements[0];
el.value = getTopColor();

el.addEventListener("input", listen);

dispatch.on("update.colorProperty", message => {
if (message !== "colorProperty") el.value = getTopColor();
});

form.value = features;
return form;

function listen(event) {
const selected = editor.selected()[0];
if (selected) {
const color = el.value;
selected.properties.color = color2css(color);
dispatch.call("data", null, "colorProperty");
}
event.preventDefault();
}
}
Insert cell
function propertiesView(editor) {
const { features, container, render, projection, options, dispatch } = editor;

function getTopProperties() {
const selected = editor.selected()[0];
return selected ? JSON.stringify(selected.properties, null, 2) : "";
}

const form = html`<form><label>Properties
<textarea style="width:100%" rows=10></textarea>
<div style="color:red; font:small"></div>
`;

const el = form.elements[0];
// Object.assign(el, props);
el.placeholder = "Select an element in the map";
el.value = getTopProperties();

el.addEventListener("input", listen);

dispatch.on("update.properties", message => {
if (message !== "properties") el.value = getTopProperties();
});

form.value = features;
return form;

function listen(event) {
const selected = editor.selected()[0];
if (selected) {
let message = "";
try {
selected.properties = JSON5.parse(el.value);
dispatch.call("data", null, "properties");
} catch (e) {
if (e instanceof SyntaxError) message = e;
} finally {
d3.select(form)
.select("div")
.text(message);
}
}
event.preventDefault();
}
}
Insert cell
function propertyView(editor, property) {
const { features, container, render, projection, options, dispatch } = editor;
function getTopProperty() {
const selected = editor.selected()[0];
return (selected && selected.properties[property]) || "";
}

const form = html`<form><label>${property}: <input type=text>`;

const el = form.elements[0];
el.value = getTopProperty();

el.addEventListener("input", listen);
form.addEventListener("submit", event => {
event.preventDefault();
});

dispatch.on("update." + property, message => {
if (message !== property) el.value = getTopProperty();
});

form.value = features;
return form;

function listen(event) {
const selected = editor.selected()[0];
if (selected) {
let value = el.value;
if (value !== "") {
if (String(Number(value)) == value) value = Number(value);
selected.properties[property] = value;
} else delete selected.properties[property];
dispatch.call("data", null, property);
}
event.preventDefault();
}
}
Insert cell
function jsonView({
features,
container,
render,
projection,
options,
dispatch
}) {
function getJSON() {
const a = JSON.parse(JSON.stringify(features));
a.features.forEach(a => {
delete a.selected;
});
return geoformat(options.precision || 8)(a);
}

const form = html`<form><label>JSON
<textarea style="width:100%" rows=20></textarea>
<div style="color:red; font:small"></div>
`;

const el = form.elements[0];
// Object.assign(el, props);
el.placeholder = "Select an element in the map";
el.value = getJSON();

el.addEventListener("input", listen);

dispatch.on("update.json", message => {
if (message !== "json") el.value = getJSON();
});

form.value = features;
return form;

function listen(event) {
let message = "";
try {
const f = JSON5.parse(el.value);
if (f.features) {
features.features.splice(0, Infinity, ...f.features);
dispatch.call("data", null, "json");
}
} catch (e) {
if (e instanceof SyntaxError) message = e;
} finally {
d3.select(form)
.select("div")
.text(message);
}
event.preventDefault();
}
}
Insert cell
// inspiration from https://observablehq.com/@tomgp/editable-table
function tableView(editor, properties) {
const { features, container, render, projection, options, dispatch } = editor;

const form = html`<table></table>`;

const sticky = ' style="position:sticky;top:0;background:white"';
function update() {
const theader = `<tr><th${sticky}>#</th>${properties
.map(d => `<td${sticky}>${d}</td>`)
.join("")}</tr>`,
tbody = features.features
.map(
(e, i) =>
`<tr data-rank=${i}><th>${i}</th>${properties
.map(d => `<td contenteditable>${e.properties[d] || ""}</td>`)
.join("")}</tr>`
)
.join("");

d3.select(form).html(
`<tbody style="display:block;max-height: 20em; overflow-y: scroll">${theader}${tbody}`
);

d3.select(form)
.selectAll("[contenteditable]")
.on("focus", function() {
const tr = d3.event.target.parentElement,
rank = +tr.getAttribute("data-rank"),
feature = features.features[rank];
features.features.forEach(d => delete d.selected);
feature.selected = true;
dispatch.call("zoomTo", null, feature);
});
}

update();

form.addEventListener("input", listen);

dispatch.on("update.table", message => {
if (message !== "table") update();
});

form.value = features;
return form;

function listen(event) {
const tr = event.target.parentElement,
rank = +tr.getAttribute("data-rank"),
feature = features.features[rank];

tr.querySelectorAll("td").forEach((td, j) => {
const property = properties[j],
text = td.textContent || "";
if (text)
feature.properties[property] =
String(Number(text)) === text ? Number(text) : text;
else delete feature.properties[property];
});
dispatch.call("data", null, "table");
event.preventDefault();
}
}
Insert cell
function buttonView({
features,
container,
render,
projection,
options,
dispatch
}) {
const wrapper = html`${ui(features, projection, dispatch)}`;

dispatch.on("update.button", () => {
wrapper.dispatchEvent(new CustomEvent("input"));
});

wrapper.value = features;
return wrapper;
}
Insert cell
function mapView({
features,
container,
render,
projection,
options,
dispatch
}) {
// default values
if (container === undefined) container = DOM.context2d(width, height).canvas;
if (features === undefined)
features = {
type: "FeatureCollection",
features: []
};
if (render === undefined)
render = defaultRenderer;

const w = parseInt(container.style.width) || container.width,
h = (w * container.height) / container.width,
elements = features.features,
svg = d3.create("svg");

const path = d3.geoPath(projection);

const wrapper = html`<div style="position:relative">
${svg.node()}
${container}
`;

svg
.style("position", "absolute")
.style("top", "0")
.style("z-index", 2)
//.style("pointer-events", "none")
.attr("viewBox", `0 0 ${w} ${h}`);

const gGraticule = svg
.append("path")
.attr("id", "graticule")
.style("fill", "none")
.style("stroke", "yellow")
.datum(d3.geoGraticule10());

const gPaths = svg.append("g").attr("id", "paths");
const gHandles = svg.append("g").attr("id", "handles");

function updateSvg() {
gPaths
.selectAll("path")
.data(elements)
.join("path")
.attr("d", path)
.style("stroke", d => d.properties.color)
.style("fill", d => d.properties.color)
.style("fill-opacity", d =>
["Point", "MultiPoint", "Polygon", "MultiPolygon"].includes(
d.geometry.type
)
? 0.4
: 0
)
.on("click", d => {
const sourceEvent = d3.event;
if (sourceEvent && sourceEvent.shiftKey) {
// multiple select
} else {
elements.forEach(d => delete d.selected);
}
d.selected = true;
dispatch.call("data", null, "selection");
});

const symbol = d3.symbol().size(40)();
gHandles
.selectAll("path")
.data(geoPoints(features.features, projection))
.join("path")
.attr("d", symbol)
.attr("transform", d => `translate(${d.xy})`)
.attr("opacity", 1)
.attr("fill", d => (d.elt.selected && !d.midpoint ? "black" : "none"))

.attr("stroke", "black");
}

svg
.on("click", d => {})
.call(
drag(elements, projection).on(
"start.render drag.render end.render",
() => {
// render(container, features, projection);
updateSvg();
dispatch.call("data", null, "drag");
}
)
);

let zoom,
transform = d3.zoomIdentity;
if (options.zoom) {
transform.x = projection.translate()[0];
transform.y = projection.translate()[1];
transform.k = projection.scale();

zoom = d3.zoom().on("zoom", () => {
transform = d3.event.transform;
projection.translate([transform.x, transform.y]).scale(transform.k);
render(container, features, projection);
updateSvg();
});
svg.call(zoom).call(zoom.transform, transform);
}

dispatch.on("update.map", () => {
wrapper.value = features;
render(container, features, projection);
updateSvg();
wrapper.dispatchEvent(new CustomEvent("input"));
});

dispatch.on(
"zoomTo",
debounce(d => {
if (zoom) {
const { x, y, k } = transform;
projection.fitExtent(
[
[width * 0.2, height * 0.2],
[width * (1 - 0.2), height * (1 - 0.2)]
],
d
);
transform.x = projection.translate()[0];
transform.y = projection.translate()[1];
transform.k = projection.scale();

// todo: the transition doesn't work
projection.translate([x, y]).scale(k);
svg
.transition()
.duration(750)
.call(zoom.transform, transform);
}
}, 400)
);

wrapper.value = features;
return wrapper;
}
Insert cell
function euclidian(a, b) {
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}
Insert cell
drag = (elements, projection) => {
function dragstarted() {
const { subject, sourceEvent } = d3.event;
subject.elt.active = true;

elements.forEach(d => delete d.selected);
subject.elt.selected = true;

// if the point is a midpoint, insert it in the ring
if (subject.midpoint) {
subject.add = true;
} else {
if (sourceEvent && sourceEvent.shiftKey) subject.remove = true;
}
}

function dragged() {
const subject = d3.event.subject;
subject.x = d3.event.x;
subject.y = d3.event.y;

delete subject.remove;
if (subject.add) {
delete subject.add;
delete subject.midpoint;
subject.coords.splice(subject.i, 0, subject);
}

const coords = projection.invert([subject.x, subject.y]);
subject[0] = coords[0];
subject[1] = coords[1];
}

function dragended() {
const { subject } = d3.event;
delete subject.elt.active;

if (subject.remove) {
const isRing = subject.closed;
if (isRing) {
subject.coords.pop();
subject.coords.splice(subject.i % subject.coords.length, 1);
subject.coords.push(subject.coords[0]);
} else {
subject.coords.splice(subject.i, 1);
}
if (subject.coords.length < 2 + isRing) {
// empty the coords list
subject.coords.splice(0, Infinity);
// remove empty rings
subject.elt.geometry.coordinates = prune(
subject.elt.geometry.coordinates
);
// remove the element if it has no remaining ring
if (subject.elt.geometry.coordinates.flat(Infinity).length === 0) {
elements.splice(elements.indexOf(subject.elt), 1);
}
}
}
}
function prune(array) {
return array
.map(d => (Array.isArray(d) ? prune(d) : d))
.filter(d => !Array.isArray(d) || d.length);
}

return d3
.drag()
.subject(subject(elements, projection, 20))
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
Insert cell
Insert cell
function ui(features, projection, dispatch) {
return html`
${uiAddPoint(features, projection, dispatch)}
${uiAddLineString(features, projection, dispatch)}
${uiAddPolygon(features, projection, dispatch)}
${uiRemoveTop(features, projection, dispatch)}`;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function randomPoint(projection) {
return (
(projection &&
projection.invert &&
projection.invert([Math.random() * width, Math.random() * height])) || [
Math.random() * 360 - 180,
90 * (Math.random() - Math.random())
]
);
}
Insert cell
height = 600
Insert cell
radius = 32
Insert cell
d3 = require("d3@5", "d3-geo-projection@2", "d3-geo-polygon@1")
Insert cell
world = {
yield null;
yield await d3.json(
"https://unpkg.com/visionscarto-world-atlas@0.0.6/world/110m_land.geojson"
);
}
Insert cell
countries = {
yield { type: "FeatureCollection", features: [] };
yield await d3.json(
"https://unpkg.com/visionscarto-world-atlas@0.0.6/world/110m_countries.geojson"
);
}
Insert cell
Insert cell
import { geoformat } from "@fil/geoformat"
Insert cell
import { color2css } from "@fil/color2css"
Insert cell
// https://github.com/json5/json5 tolerant JSON.parse
JSON5 = require("json5")
Insert cell
debounce = require("debounce").catch(() => window.debounce)
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