Topolis
Runnable OpenLayers example: Topolis. Open the live demo, read the complete source, and copy the file into your project. No build step or API key.
single HTML file · CDN dependencies onlyinterface may contain Chinese labels
Source
<!--
示例:OpenLayers 拓扑编辑
要点:节点、边、面三级拓扑结构,绘制时自动吸附端点并实时重算封闭面
依赖:OpenLayers 10.3.1 · jsDelivr CDN
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="robots" content="noindex,follow">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>拓扑编辑</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@10.3.1/ol.css">
<style>
html,body{height:100%;margin:0}
body{display:flex;flex-direction:column;overflow:hidden;
font:13px/1.6 -apple-system,"Segoe UI","PingFang SC",sans-serif}
.bar{flex:0 0 auto;padding:8px 12px;background:#f6f7f9;border-bottom:1px solid #e3e6ea;
display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:12px;color:#58606e}
.bar label{display:flex;gap:5px;align-items:center;cursor:pointer}
.bar select,.bar input[type=range]{font-size:12px}
.bar .val{font-family:ui-monospace,Consolas,monospace;color:#1f5fd0;min-width:44px}
.bar .sep{width:1px;height:16px;background:#d8dce2}
#map{flex:1 1 auto;min-height:0;background:#eef0f3}
</style>
</head>
<body>
<div class="bar">
<span>在已有节点之间画线,闭合后自动生成面</span>
<span class="sep"></span>
<span id="info">节点 4 · 边 5 · 面 2</span>
</div>
<div id="map"></div>
<script src="https://cdn.jsdelivr.net/npm/ol@10.3.1/dist/ol.js"></script>
<script>
(function () {
'use strict';
if (typeof ol === 'undefined') {
document.getElementById('map').textContent = 'OpenLayers 加载失败,请检查能否访问 cdn.jsdelivr.net';
return;
}
var center = ol.proj.fromLonLat([116.391, 39.907]);
var map = new ol.Map({
target: 'map',
layers: [new ol.layer.Tile({source: new ol.source.XYZ({url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', attributions: 'Tiles © Esri'}), opacity: 0.7})],
view: new ol.View({center: center, zoom: 16})
});
var nodeSeq = 1;
var edgeSeq = 1;
var nodes = {};
var edges = [];
function addNode(lon, lat) {
var id = nodeSeq++;
nodes[id] = ol.proj.fromLonLat([lon, lat]);
return id;
}
function addEdge(a, b) {
edges.push({id: edgeSeq++, a: a, b: b});
}
var n1 = addNode(116.380, 39.903);
var n2 = addNode(116.398, 39.903);
var n3 = addNode(116.398, 39.913);
var n4 = addNode(116.380, 39.913);
addEdge(n1, n2);
addEdge(n2, n3);
addEdge(n3, n4);
addEdge(n4, n1);
addEdge(n1, n3);
var nodesSource = new ol.source.Vector();
var edgesSource = new ol.source.Vector();
var facesSource = new ol.source.Vector();
var nodesLayer = new ol.layer.Vector({
source: nodesSource,
zIndex: 3,
style: function (feature) {
return new ol.style.Style({
image: new ol.style.Circle({
radius: 7,
fill: new ol.style.Fill({color: 'rgba(220, 40, 40, 0.25)'}),
stroke: new ol.style.Stroke({color: '#c62828', width: 1})
}),
text: new ol.style.Text({
text: String(feature.getId()),
font: '600 11px sans-serif',
offsetY: -16,
fill: new ol.style.Fill({color: '#b71c1c'}),
stroke: new ol.style.Stroke({color: '#fff', width: 3})
})
});
}
});
var edgesLayer = new ol.layer.Vector({
source: edgesSource,
zIndex: 2,
style: function (feature) {
return new ol.style.Style({
stroke: new ol.style.Stroke({color: '#1565c0', width: 2}),
text: new ol.style.Text({
text: String(feature.getId()),
font: '600 11px sans-serif',
fill: new ol.style.Fill({color: '#0d47a1'}),
stroke: new ol.style.Stroke({color: '#fff', width: 2})
})
});
}
});
var facesLayer = new ol.layer.Vector({
source: facesSource,
zIndex: 1,
style: function (feature) {
return new ol.style.Style({
fill: new ol.style.Fill({color: 'rgba(56, 142, 60, 0.18)'}),
stroke: new ol.style.Stroke({color: '#2e7d32', width: 1}),
text: new ol.style.Text({
text: '面 ' + feature.getId(),
font: '600 12px sans-serif',
fill: new ol.style.Fill({color: '#1b5e20'}),
stroke: new ol.style.Stroke({color: '#fff', width: 3})
})
});
}
});
map.addLayer(facesLayer);
map.addLayer(edgesLayer);
map.addLayer(nodesLayer);
function edgeAngle(half) {
var a = nodes[half.from];
var b = nodes[half.to];
return Math.atan2(b[1] - a[1], b[0] - a[0]);
}
function signedArea(ring) {
var sum = 0;
for (var i = 0; i < ring.length; ++i) {
var p = nodes[ring[i]];
var q = nodes[ring[(i + 1) % ring.length]];
sum += p[0] * q[1] - q[0] * p[1];
}
return sum / 2;
}
function findFaces() {
var half = [];
var outgoing = {};
edges.forEach(function (edge, index) {
half.push({from: edge.a, to: edge.b, id: index * 2, twin: index * 2 + 1});
half.push({from: edge.b, to: edge.a, id: index * 2 + 1, twin: index * 2});
});
half.forEach(function (h) {
(outgoing[h.from] || (outgoing[h.from] = [])).push(h);
});
Object.keys(outgoing).forEach(function (key) {
outgoing[key].sort(function (p, q) {
return edgeAngle(p) - edgeAngle(q);
});
});
var visited = {};
var rings = [];
half.forEach(function (start) {
if (visited[start.id]) {
return;
}
var ring = [];
var current = start;
var guard = 0;
do {
visited[current.id] = true;
ring.push(current.from);
var candidates = outgoing[current.to];
var twinIndex = candidates.indexOf(
candidates.filter(function (c) { return c.id === current.twin; })[0]
);
current = candidates[(twinIndex + candidates.length - 1) % candidates.length];
++guard;
} while (current.id !== start.id && guard < 400);
if (ring.length >= 3 && signedArea(ring) > 0) {
rings.push(ring);
}
});
return rings;
}
var faceSeq = 1;
function refresh() {
nodesSource.clear();
edgesSource.clear();
facesSource.clear();
Object.keys(nodes).forEach(function (id) {
var feature = new ol.Feature(new ol.geom.Point(nodes[id]));
feature.setId(Number(id));
nodesSource.addFeature(feature);
});
edges.forEach(function (edge) {
var feature = new ol.Feature(
new ol.geom.LineString([nodes[edge.a], nodes[edge.b]])
);
feature.setId(edge.id);
edgesSource.addFeature(feature);
});
faceSeq = 1;
findFaces().forEach(function (ring) {
var ringCoords = ring.map(function (id) { return nodes[id]; });
ringCoords.push(ringCoords[0]);
var feature = new ol.Feature(new ol.geom.Polygon([ringCoords]));
feature.setId(faceSeq++);
facesSource.addFeature(feature);
});
document.getElementById('info').textContent =
'节点 ' + Object.keys(nodes).length +
' · 边 ' + edges.length +
' · 面 ' + facesSource.getFeatures().length;
}
refresh();
function nearestNode(coordinate) {
var best = null;
var bestDist = Infinity;
var tolerance = map.getView().getResolution() * 12;
Object.keys(nodes).forEach(function (id) {
var dx = nodes[id][0] - coordinate[0];
var dy = nodes[id][1] - coordinate[1];
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) {
bestDist = dist;
best = Number(id);
}
});
return bestDist <= tolerance ? best : null;
}
function newNode(coordinate) {
var id = nodeSeq++;
nodes[id] = coordinate;
return id;
}
var drawSource = new ol.source.Vector();
var drawLayer = new ol.layer.Vector({
source: drawSource,
style: new ol.style.Style({
stroke: new ol.style.Stroke({color: 'rgba(255, 160, 0, 0.95)', width: 2})
})
});
map.addLayer(drawLayer);
var draw = new ol.interaction.Draw({
type: 'LineString',
source: drawSource
});
draw.on('drawend', function (evt) {
var coords = evt.feature.getGeometry().getCoordinates();
var start = nearestNode(coords[0]);
var end = nearestNode(coords[coords.length - 1]);
if (start === null) {
start = newNode(coords[0]);
}
if (end === null) {
end = newNode(coords[coords.length - 1]);
}
var duplicate = edges.some(function (edge) {
return (edge.a === start && edge.b === end) ||
(edge.a === end && edge.b === start);
});
if (start !== end && !duplicate) {
addEdge(start, end);
}
drawSource.clear();
refresh();
});
map.addInteraction(draw);
map.addInteraction(new ol.interaction.Snap({source: nodesSource}));
})();
</script>
</body>
</html>
Language: the code and this page are in English; the full tutorial and API notes for this example are in Chinese. The demo itself is the authoritative version — it runs exactly as shown.