Try it. Four towns sit at the corners of a square. Toggle between the spanning tree (connect towns directly) and the Steiner tree (add two junction points where cables meet at 120°). The total length and the saving update as you switch.
<p class="hint">Four towns at the corners of a square (10 km a side). Switch between the plain <b>spanning tree</b> and the <b>Steiner tree</b> with two extra junctions, and watch the total cable — and the saving — change.</p>
<div class="toggle">
<button id="bmst" type="button" class="active">Spanning tree</button>
<button id="bst" type="button">Steiner tree</button>
</div>
<svg id="svg" viewBox="0 0 300 270" class="svg"></svg>
<div class="meter">
<div>Total cable: <b id="len">—</b></div>
<div id="save" class="save"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.toggle { display: flex; gap: 0; margin-bottom: .7rem; border: 1px solid #457b9d; border-radius: 8px; overflow: hidden; width: fit-content; }
.toggle button { font: 600 14px system-ui; padding: .5rem 1.1rem; border: none; background: #fff; color: #457b9d; cursor: pointer; }
.toggle button.active { background: #457b9d; color: #fff; }
.svg { width: 100%; max-width: 420px; background: #f4f7f9; border: 1px solid #e2e6eb; border-radius: 10px; display: block; }
.cable { stroke: #2a9d8f; stroke-width: 3.5; stroke-linecap: round; }
.town { fill: #1d3557; }
.tlbl { font: 700 12px system-ui; fill: #1d3557; text-anchor: middle; }
.steiner { fill: #e76f51; }
.slbl { font: 600 10px system-ui; fill: #e76f51; text-anchor: middle; }
.meter { display: flex; gap: 1.5rem; align-items: center; margin-top: .8rem; font-size: 1.05rem; flex-wrap: wrap; }
.meter b { color: #1d3557; }
.save { font-weight: 800; color: #0a7d33; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Steiner tree vs minimum spanning tree on a unit square.
// Spanning tree = 3 sides = 30 km. Steiner tree adds two junction points where cables meet at 120 degrees, for the provably shortest network (~27.3 km).
const SCALE = 18; // px per km, square side = 10 km
// towns (corners), px coords
const T = { A: [60, 40], B: [240, 40], C: [60, 220], D: [240, 220] };
// Steiner junctions on the horizontal midline
const t = 0.5 * (1 - 1 / Math.sqrt(3)) * 180;
const S1 = [150 - t, 130], S2 = [150 + t, 130];
function dist(p, q) { return Math.hypot(p[0] - q[0], p[1] - q[1]); }
const MST_EDGES = [['A', 'B'], ['A', 'C'], ['B', 'D']]; // 3 sides
function mstLen() { return MST_EDGES.reduce((s, [a, b]) => s + dist(T[a], T[b]), 0) / SCALE; }
const ST_SEGMENTS = () => [[T.A, S1], [T.C, S1], [T.B, S2], [T.D, S2], [S1, S2]];
function stLen() { return ST_SEGMENTS().reduce((s, [p, q]) => s + dist(p, q), 0) / SCALE; }
let mode = 'mst';
function render() {
let s = '';
if (mode === 'mst') {
MST_EDGES.forEach(([a, b]) => { s += line(T[a], T[b]); });
} else {
ST_SEGMENTS().forEach(([p, q]) => { s += line(p, q); });
[S1, S2].forEach((p, i) => { s += '<circle class="steiner" cx="' + p[0] + '" cy="' + p[1] + '" r="6"/>'; });
s += fmt('<text class="slbl" x="%v%" y="%v2%%v3%">junction</text>', { v: S1[0], v2: S1[1], v3: 20 });
s += fmt('<text class="slbl" x="%v%" y="%v2%%v3%">junction</text>', { v: S2[0], v2: S2[1], v3: 20 });
}
for (const k in T) { const p = T[k]; s += '<circle class="town" cx="' + p[0] + '" cy="' + p[1] + '" r="8"/>'; s += '<text class="tlbl" x="' + p[0] + '" y="' + (p[1] + (p[1] < 130 ? -14 : 24)) + '">' + k + '</text>'; }
document.getElementById('svg').innerHTML = s;
const mst = mstLen(), st = stLen();
const cur = mode === 'mst' ? mst : st;
document.getElementById('len').textContent = fmt('%cur% km', { cur: cur.toFixed(1) });
const save = document.getElementById('save');
if (mode === 'st') { save.textContent = fmt('Saves %v% km (%round%%) vs the spanning tree.', { v: (mst - st).toFixed(1), round: Math.round((1 - st / mst) * 100) }); }
else { save.textContent = 'Switch to the Steiner tree to see the saving.'; save.style.color = '#777'; }
if (mode === 'st') save.style.color = '#0a7d33';
}
function line(p, q) { return '<line class="cable" x1="' + p[0] + '" y1="' + p[1] + '" x2="' + q[0] + '" y2="' + q[1] + '"/>'; }
document.getElementById('bmst').addEventListener('click', () => { mode = 'mst'; document.getElementById('bmst').classList.add('active'); document.getElementById('bst').classList.remove('active'); render(); });
document.getElementById('bst').addEventListener('click', () => { mode = 'st'; document.getElementById('bst').classList.add('active'); document.getElementById('bmst').classList.remove('active'); render(); });
render();
<p class="hint">Four towns at the corners of a square (10 km a side). Switch between the plain <b>spanning tree</b> and the <b>Steiner tree</b> with two extra junctions, and watch the total cable — and the saving — change.</p>
<div class="toggle">
<button id="bmst" type="button" class="active">Spanning tree</button>
<button id="bst" type="button">Steiner tree</button>
</div>
<svg id="svg" viewBox="0 0 300 270" class="svg"></svg>
<div class="meter">
<div>Total cable: <b id="len">—</b></div>
<div id="save" class="save"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.toggle { display: flex; gap: 0; margin-bottom: .7rem; border: 1px solid #457b9d; border-radius: 8px; overflow: hidden; width: fit-content; }
.toggle button { font: 600 14px system-ui; padding: .5rem 1.1rem; border: none; background: #fff; color: #457b9d; cursor: pointer; }
.toggle button.active { background: #457b9d; color: #fff; }
.svg { width: 100%; max-width: 420px; background: #f4f7f9; border: 1px solid #e2e6eb; border-radius: 10px; display: block; }
.cable { stroke: #2a9d8f; stroke-width: 3.5; stroke-linecap: round; }
.town { fill: #1d3557; }
.tlbl { font: 700 12px system-ui; fill: #1d3557; text-anchor: middle; }
.steiner { fill: #e76f51; }
.slbl { font: 600 10px system-ui; fill: #e76f51; text-anchor: middle; }
.meter { display: flex; gap: 1.5rem; align-items: center; margin-top: .8rem; font-size: 1.05rem; flex-wrap: wrap; }
.meter b { color: #1d3557; }
.save { font-weight: 800; color: #0a7d33; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Steiner tree vs minimum spanning tree on a unit square.
// Spanning tree = 3 sides = 30 km. Steiner tree adds two junction points where cables meet at 120 degrees, for the provably shortest network (~27.3 km).
const SCALE = 18; // px per km, square side = 10 km
// towns (corners), px coords
const T = { A: [60, 40], B: [240, 40], C: [60, 220], D: [240, 220] };
// Steiner junctions on the horizontal midline
const t = 0.5 * (1 - 1 / Math.sqrt(3)) * 180;
const S1 = [150 - t, 130], S2 = [150 + t, 130];
function dist(p, q) { return Math.hypot(p[0] - q[0], p[1] - q[1]); }
const MST_EDGES = [['A', 'B'], ['A', 'C'], ['B', 'D']]; // 3 sides
function mstLen() { return MST_EDGES.reduce((s, [a, b]) => s + dist(T[a], T[b]), 0) / SCALE; }
const ST_SEGMENTS = () => [[T.A, S1], [T.C, S1], [T.B, S2], [T.D, S2], [S1, S2]];
function stLen() { return ST_SEGMENTS().reduce((s, [p, q]) => s + dist(p, q), 0) / SCALE; }
let mode = 'mst';
function render() {
let s = '';
if (mode === 'mst') {
MST_EDGES.forEach(([a, b]) => { s += line(T[a], T[b]); });
} else {
ST_SEGMENTS().forEach(([p, q]) => { s += line(p, q); });
[S1, S2].forEach((p, i) => { s += '<circle class="steiner" cx="' + p[0] + '" cy="' + p[1] + '" r="6"/>'; });
s += fmt('<text class="slbl" x="%v%" y="%v2%%v3%">junction</text>', { v: S1[0], v2: S1[1], v3: 20 });
s += fmt('<text class="slbl" x="%v%" y="%v2%%v3%">junction</text>', { v: S2[0], v2: S2[1], v3: 20 });
}
for (const k in T) { const p = T[k]; s += '<circle class="town" cx="' + p[0] + '" cy="' + p[1] + '" r="8"/>'; s += '<text class="tlbl" x="' + p[0] + '" y="' + (p[1] + (p[1] < 130 ? -14 : 24)) + '">' + k + '</text>'; }
document.getElementById('svg').innerHTML = s;
const mst = mstLen(), st = stLen();
const cur = mode === 'mst' ? mst : st;
document.getElementById('len').textContent = fmt('%cur% km', { cur: cur.toFixed(1) });
const save = document.getElementById('save');
if (mode === 'st') { save.textContent = fmt('Saves %v% km (%round%%) vs the spanning tree.', { v: (mst - st).toFixed(1), round: Math.round((1 - st / mst) * 100) }); }
else { save.textContent = 'Switch to the Steiner tree to see the saving.'; save.style.color = '#777'; }
if (mode === 'st') save.style.color = '#0a7d33';
}
function line(p, q) { return '<line class="cable" x1="' + p[0] + '" y1="' + p[1] + '" x2="' + q[0] + '" y2="' + q[1] + '"/>'; }
document.getElementById('bmst').addEventListener('click', () => { mode = 'mst'; document.getElementById('bmst').classList.add('active'); document.getElementById('bst').classList.remove('active'); render(); });
document.getElementById('bst').addEventListener('click', () => { mode = 'st'; document.getElementById('bst').classList.add('active'); document.getElementById('bmst').classList.remove('active'); render(); });
render();
The spanning tree uses three full sides; the Steiner tree, with its two clever junctions, is noticeably shorter. That little saving — around 9% here, and provably the best possible for a square — is exactly the prize that makes the problem worth its NP-hard difficulty.
Comments
Loading comments...