Show AMPGateway maps as primary visuals

This commit is contained in:
jwinterm
2026-07-07 13:00:33 -04:00
parent 2e539ddfd9
commit 34434d082a
3 changed files with 402 additions and 198 deletions
+165 -137
View File
@@ -1,18 +1,6 @@
(function () { (function () {
const canvas = document.getElementById("topologyCanvas");
const caption = document.getElementById("topologyCaption");
const buttons = Array.from(document.querySelectorAll("[data-topology]"));
const signalWord = document.getElementById("signalWord"); const signalWord = document.getElementById("signalWord");
if (!canvas || !caption || buttons.length === 0) {
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
const palette = { const palette = {
text: "#f6f1e4", text: "#f6f1e4",
muted: "rgba(246, 241, 228, 0.52)", muted: "rgba(246, 241, 228, 0.52)",
@@ -110,127 +98,7 @@
} }
}; };
const words = ["clearnet", "Tor", "I2P", "Gemini", "IPFS", "Reticulum"]; if (window.CanvasRenderingContext2D && !CanvasRenderingContext2D.prototype.roundRect) {
let wordIndex = 0;
let active = "vps";
let width = 0;
let height = 0;
let start = performance.now();
function resize() {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = Math.max(1, rect.width);
height = Math.max(1, rect.height);
canvas.width = Math.floor(width * ratio);
canvas.height = Math.floor(height * ratio);
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
}
function nodeMap(topology) {
return new Map(topology.nodes.map((node) => [node[0], node]));
}
function draw(now) {
const topology = topologies[active];
const nodes = nodeMap(topology);
const tick = (now - start) / 1000;
ctx.clearRect(0, 0, width, height);
drawField(tick);
topology.links.forEach((link, index) => drawLink(nodes.get(link[0]), nodes.get(link[1]), tick, index));
topology.nodes.forEach((node, index) => drawNode(node, tick, index));
requestAnimationFrame(draw);
}
function drawField(tick) {
ctx.save();
ctx.globalAlpha = 0.38;
for (let i = 0; i < 36; i += 1) {
const x = ((i * 137 + tick * 16) % (width + 120)) - 60;
const y = ((i * 71 + tick * 9) % (height + 120)) - 60;
ctx.fillStyle = i % 3 === 0 ? palette.teal : i % 3 === 1 ? palette.amber : palette.green;
ctx.beginPath();
ctx.arc(x, y, i % 2 === 0 ? 1.6 : 1, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawLink(from, to, tick, index) {
if (!from || !to) return;
const ax = from[2] * width;
const ay = from[3] * height;
const bx = to[2] * width;
const by = to[3] * height;
ctx.save();
ctx.strokeStyle = palette.line;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
const progress = (tick * 0.22 + index * 0.13) % 1;
const px = ax + (bx - ax) * progress;
const py = ay + (by - ay) * progress;
ctx.fillStyle = index % 2 === 0 ? palette.teal : palette.amber;
ctx.beginPath();
ctx.arc(px, py, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawNode(node, tick, index) {
const [, label, nx, ny, colorKey] = node;
const x = nx * width;
const y = ny * height;
const pulse = Math.sin(tick * 2.2 + index) * 2;
const color = palette[colorKey] || palette.teal;
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 20;
ctx.fillStyle = "rgba(16, 16, 15, 0.86)";
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x - 58, y - 23, 116, 46, 8);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = palette.text;
ctx.font = "700 13px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, x, y + pulse * 0.25);
ctx.restore();
}
function setTopology(name) {
if (!topologies[name]) return;
active = name;
caption.textContent = topologies[name].caption;
buttons.forEach((button) => {
const selected = button.dataset.topology === name;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", selected ? "true" : "false");
});
}
buttons.forEach((button) => {
button.addEventListener("click", () => setTopology(button.dataset.topology));
});
if (signalWord) {
window.setInterval(() => {
wordIndex = (wordIndex + 1) % words.length;
signalWord.textContent = words[wordIndex];
signalWord.classList.remove("swap");
void signalWord.offsetWidth;
signalWord.classList.add("swap");
}, 1800);
}
if (!CanvasRenderingContext2D.prototype.roundRect) {
CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) { CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) {
this.beginPath(); this.beginPath();
this.moveTo(x + r, y); this.moveTo(x + r, y);
@@ -246,8 +114,168 @@
}; };
} }
window.addEventListener("resize", resize); function createTopologyMap(options) {
resize(); const canvas = document.getElementById(options.canvasId);
setTopology(active); const caption = document.getElementById(options.captionId);
requestAnimationFrame(draw); const buttons = Array.from(document.querySelectorAll(options.buttonSelector));
if (!canvas || !caption || buttons.length === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let active = options.initial || "vps";
let width = 0;
let height = 0;
const start = performance.now();
function resize() {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = Math.max(1, rect.width);
height = Math.max(1, rect.height);
canvas.width = Math.floor(width * ratio);
canvas.height = Math.floor(height * ratio);
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
}
function nodeMap(topology) {
return new Map(topology.nodes.map((node) => [node[0], node]));
}
function draw(now) {
const topology = topologies[active];
const nodes = nodeMap(topology);
const tick = (now - start) / 1000;
ctx.clearRect(0, 0, width, height);
drawField(tick);
topology.links.forEach((link, index) => {
drawLink(nodes.get(link[0]), nodes.get(link[1]), tick, index);
});
topology.nodes.forEach((node, index) => drawNode(node, tick, index));
requestAnimationFrame(draw);
}
function drawField(tick) {
ctx.save();
ctx.globalAlpha = options.fieldAlpha || 0.38;
const dotCount = options.dotCount || 36;
for (let i = 0; i < dotCount; i += 1) {
const x = ((i * 137 + tick * 16) % (width + 120)) - 60;
const y = ((i * 71 + tick * 9) % (height + 120)) - 60;
ctx.fillStyle = i % 3 === 0 ? palette.teal : i % 3 === 1 ? palette.amber : palette.green;
ctx.beginPath();
ctx.arc(x, y, i % 2 === 0 ? 1.6 : 1, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawLink(from, to, tick, index) {
if (!from || !to) return;
const ax = from[2] * width;
const ay = from[3] * height;
const bx = to[2] * width;
const by = to[3] * height;
ctx.save();
ctx.strokeStyle = palette.line;
ctx.lineWidth = options.lineWidth || 1;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
const progress = (tick * 0.22 + index * 0.13) % 1;
const px = ax + (bx - ax) * progress;
const py = ay + (by - ay) * progress;
ctx.fillStyle = index % 2 === 0 ? palette.teal : palette.amber;
ctx.beginPath();
ctx.arc(px, py, options.packetRadius || 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawNode(node, tick, index) {
const [, label, nx, ny, colorKey] = node;
const x = nx * width;
const y = ny * height;
const boxWidth = options.nodeWidth || 116;
const boxHeight = options.nodeHeight || 46;
const pulse = Math.sin(tick * 2.2 + index) * 2;
const color = palette[colorKey] || palette.teal;
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = options.shadowBlur || 20;
ctx.fillStyle = "rgba(16, 16, 15, 0.86)";
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x - boxWidth / 2, y - boxHeight / 2, boxWidth, boxHeight, 8);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = palette.text;
ctx.font = `${options.fontWeight || 700} ${options.fontSize || 13}px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, x, y + pulse * 0.25);
ctx.restore();
}
function setTopology(name) {
if (!topologies[name]) return;
active = name;
caption.textContent = topologies[name].caption;
buttons.forEach((button) => {
const selected = button.dataset[options.dataKey] === name;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", selected ? "true" : "false");
});
}
buttons.forEach((button) => {
button.addEventListener("click", () => setTopology(button.dataset[options.dataKey]));
});
window.addEventListener("resize", resize);
resize();
setTopology(active);
requestAnimationFrame(draw);
}
createTopologyMap({
canvasId: "topologyCanvas",
captionId: "topologyCaption",
buttonSelector: "[data-topology]",
dataKey: "topology",
initial: "vps"
});
createTopologyMap({
canvasId: "cleanTopologyCanvas",
captionId: "cleanTopologyCaption",
buttonSelector: "[data-clean-topology]",
dataKey: "cleanTopology",
initial: "vps",
dotCount: 70,
fieldAlpha: 0.5,
lineWidth: 1.4,
packetRadius: 4.5,
nodeWidth: 144,
nodeHeight: 54,
fontSize: 15,
fontWeight: 800,
shadowBlur: 26
});
if (signalWord) {
const words = ["clearnet", "Tor", "I2P", "Gemini", "IPFS", "Reticulum"];
let wordIndex = 0;
window.setInterval(() => {
wordIndex = (wordIndex + 1) % words.length;
signalWord.textContent = words[wordIndex];
signalWord.classList.remove("swap");
void signalWord.offsetWidth;
signalWord.classList.add("swap");
}, 1800);
}
})(); })();
+89 -44
View File
@@ -21,7 +21,8 @@
<span>AMPGateway</span> <span>AMPGateway</span>
</a> </a>
<nav class="top-nav" aria-label="Primary"> <nav class="top-nav" aria-label="Primary">
<a href="#transport-flows">Transports</a> <a href="#live-map">Live Map</a>
<a href="#protocol-maps">Protocol Maps</a>
<a href="#topologies">Topologies</a> <a href="#topologies">Topologies</a>
<a href="#domains">Domains</a> <a href="#domains">Domains</a>
<a href="#flow">Flow</a> <a href="#flow">Flow</a>
@@ -45,7 +46,7 @@
without losing the map. without losing the map.
</p> </p>
<div class="hero-actions" aria-label="Primary sections"> <div class="hero-actions" aria-label="Primary sections">
<a class="button primary" href="#transport-flows">Explore transports</a> <a class="button primary" href="#live-map">Open the map</a>
<a class="button secondary" href="#domains">See example domains</a> <a class="button secondary" href="#domains">See example domains</a>
</div> </div>
</div> </div>
@@ -63,63 +64,107 @@
</div> </div>
</section> </section>
<section id="transport-flows" class="section transport-section" aria-labelledby="transport-flows-title"> <section id="live-map" class="section live-map-section" aria-labelledby="live-map-title">
<div class="section-heading"> <div class="section-heading">
<p class="eyebrow">Traffic flows</p> <p class="eyebrow">Topology theatre</p>
<h2 id="transport-flows-title">Same source, different paths through the network.</h2> <h2 id="live-map-title">The animated maps, unobstructed.</h2>
<p> <p>
AMPG keeps the authoring path consistent while each transport uses the shape The same live topology views from the hero are given a full clean stage here,
that fits its network, daemon, and client expectations. so the moving routes, nodes, and deployment shapes can be read directly.
</p> </p>
</div> </div>
<div class="transport-flow-grid"> <div class="map-theatre" aria-label="Interactive topology map">
<article class="transport-flow"> <canvas id="cleanTopologyCanvas" aria-hidden="true"></canvas>
<div class="map-theatre-controls">
<div class="mode-label">View</div>
<div class="segmented" role="group" aria-label="Clean topology selection">
<button class="mode-button active" type="button" data-clean-topology="vps">VPS</button>
<button class="mode-button" type="button" data-clean-topology="router">Router</button>
<button class="mode-button" type="button" data-clean-topology="phone">Phone</button>
<button class="mode-button" type="button" data-clean-topology="browser">Browser</button>
</div>
<p id="cleanTopologyCaption" class="topology-caption" aria-live="polite">
A VPS can publish clearnet, Tor, I2P, Gemini, and Reticulum outputs from one AMPG plan.
</p>
</div>
</div>
</section>
<section id="protocol-maps" class="section protocol-map-section" aria-labelledby="protocol-maps-title">
<div class="section-heading">
<p class="eyebrow">Protocol maps</p>
<h2 id="protocol-maps-title">How traffic moves on each transport.</h2>
<p>
These are the big maps: what the client talks to, where AMPG output sits,
and which network pieces carry the request.
</p>
</div>
<div class="protocol-map-stack">
<article class="protocol-map">
<h3>Clearnet</h3> <h3>Clearnet</h3>
<ol> <div class="path-map" aria-label="Clearnet request path">
<li>Visitor resolves DNS for the site domain.</li> <span>Visitor browser</span>
<li>HTTP or HTTPS reaches the public web server.</li> <span>DNS</span>
<li>The server reads AMPG clearnet output.</li> <span>HTTPS ingress</span>
</ol> <span>Web server</span>
<span>AMPG clearnet output</span>
</div>
<p>Best when a public domain and inbound web reachability are available.</p>
</article> </article>
<article class="transport-flow"> <article class="protocol-map">
<h3>Tor onion</h3> <h3>Tor onion</h3>
<ol> <div class="path-map" aria-label="Tor onion request path">
<li>Tor client resolves the onion service descriptor.</li> <span>Tor Browser</span>
<li>Tor circuits connect to the hidden service.</li> <span>Tor circuits</span>
<li>The local service serves AMPG privacy HTML.</li> <span>Onion service</span>
</ol> <span>Local HTTP</span>
<span>AMPG privacy HTML</span>
</div>
<p>The public address is the onion name; the origin can stay behind local loopback.</p>
</article> </article>
<article class="transport-flow"> <article class="protocol-map">
<h3>I2P tunnel</h3> <h3>I2P tunnel</h3>
<ol> <div class="path-map" aria-label="I2P tunnel request path">
<li>I2P client resolves the b32 destination.</li> <span>I2P browser</span>
<li>Inbound and outbound tunnels carry HTTP.</li> <span>b32 destination</span>
<li>i2pd forwards to the local AMPG output.</li> <span>I2P tunnels</span>
</ol> <span>i2pd server tunnel</span>
<span>AMPG privacy HTML</span>
</div>
<p>The b32 address points at the I2P destination, not a normal DNS hostname.</p>
</article> </article>
<article class="transport-flow"> <article class="protocol-map">
<h3>Gemini</h3> <h3>Gemini</h3>
<ol> <div class="path-map" aria-label="Gemini request path">
<li>Client opens a TLS connection to the capsule.</li> <span>Gemini client</span>
<li>Gemini server maps the URL to Gemtext.</li> <span>TLS capsule</span>
<li>The rendered page keeps links and headings simple.</li> <span>Gemini daemon</span>
</ol> <span>Gemtext tree</span>
<span>AMPG text render</span>
</div>
<p>Small pages, simple links, and text-first structure make the capsule readable.</p>
</article> </article>
<article class="transport-flow"> <article class="protocol-map">
<h3>Reticulum</h3> <h3>Reticulum</h3>
<ol> <div class="path-map" aria-label="Reticulum request path">
<li>Client reaches an RNS destination.</li> <span>RNS client</span>
<li>NomadNet-style pages request compact Micron content.</li> <span>Reticulum mesh</span>
<li>AMPG output keeps media and layout intentionally small.</li> <span>Destination hash</span>
</ol> <span>NomadNet node</span>
<span>AMPG Micron output</span>
</div>
<p>Identity is destination-based, and content should stay compact by default.</p>
</article> </article>
<article class="transport-flow"> <article class="protocol-map">
<h3>Single transport</h3> <h3>Single transport</h3>
<ol> <div class="path-map" aria-label="Single transport deployment path">
<li>Operator enables only the target protocol.</li> <span>Source site</span>
<li>AMPG builds only the selected output.</li> <span>AMPG selection</span>
<li>Deploy stages skip unrelated daemons and DNS.</li> <span>One renderer</span>
</ol> <span>One daemon</span>
<span>One published route</span>
</div>
<p>Useful for Tor-only, I2P-only, Gemini-only, or Reticulum-focused sites.</p>
</article> </article>
</div> </div>
</section> </section>
+148 -17
View File
@@ -112,7 +112,8 @@ a {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
#topologyCanvas { #topologyCanvas,
#cleanTopologyCanvas {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
@@ -299,7 +300,7 @@ h3 {
.topology-grid, .topology-grid,
.domain-grid, .domain-grid,
.transport-flow-grid { .protocol-map-stack {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 14px;
@@ -307,7 +308,7 @@ h3 {
.topology-grid article, .topology-grid article,
.domain-card, .domain-card,
.transport-flow { .protocol-map {
min-height: 190px; min-height: 190px;
padding: 20px; padding: 20px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -317,7 +318,7 @@ h3 {
.topology-grid article:nth-child(2), .topology-grid article:nth-child(2),
.domain-card.featured, .domain-card.featured,
.transport-flow:nth-child(2n) { .protocol-map:nth-child(2n) {
background: var(--panel-strong); background: var(--panel-strong);
} }
@@ -325,7 +326,7 @@ h3 {
.domain-card p, .domain-card p,
.docs-section p, .docs-section p,
.flow-list p, .flow-list p,
.transport-flow li, .protocol-map p,
.section-heading p { .section-heading p {
color: var(--muted); color: var(--muted);
} }
@@ -334,23 +335,121 @@ h3 {
margin-bottom: 28px; margin-bottom: 28px;
} }
.transport-flow-grid { .live-map-section {
grid-template-columns: repeat(3, minmax(0, 1fr)); width: min(1440px, calc(100% - 36px));
} }
.transport-flow { .map-theatre {
min-height: 250px; position: relative;
min-height: clamp(540px, 72vw, 820px);
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: #10100f;
} }
.transport-flow h3 { .map-theatre::after {
font-size: 1.35rem; content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(rgba(16, 16, 15, 0.08), rgba(16, 16, 15, 0.28)),
radial-gradient(circle at 50% 50%, transparent 0 56%, rgba(16, 16, 15, 0.42) 100%);
} }
.transport-flow ol { .map-theatre-controls {
position: absolute;
left: 18px;
right: 18px;
bottom: 18px;
z-index: 2;
display: grid; display: grid;
gap: 10px; grid-template-columns: 84px minmax(0, 520px) minmax(260px, 1fr);
margin: 16px 0 0; gap: 12px;
padding-left: 20px; align-items: center;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(16, 16, 15, 0.78);
backdrop-filter: blur(18px);
}
.map-theatre-controls .mode-label,
.map-theatre-controls .topology-caption {
margin: 0;
}
.protocol-map-section {
width: min(1360px, calc(100% - 36px));
}
.protocol-map-stack {
grid-template-columns: 1fr;
gap: 18px;
}
.protocol-map {
display: grid;
grid-template-columns: 180px minmax(0, 1fr) 280px;
gap: clamp(18px, 3vw, 34px);
align-items: center;
min-height: 210px;
}
.protocol-map h3 {
margin-bottom: 0;
font-size: clamp(1.45rem, 2.4vw, 2.4rem);
}
.path-map {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 12px;
align-items: center;
}
.path-map span {
position: relative;
display: grid;
min-height: 78px;
place-items: center;
padding: 12px;
border: 1px solid rgba(246, 241, 228, 0.26);
border-radius: 8px;
background: rgba(246, 241, 228, 0.055);
color: var(--text);
font-size: 0.92rem;
font-weight: 900;
text-align: center;
}
.path-map span:not(:last-child)::after {
content: "";
position: absolute;
top: 50%;
right: -13px;
width: 13px;
height: 2px;
background: var(--amber);
transform: translateY(-50%);
}
.path-map span:nth-child(1) {
border-color: var(--green);
}
.path-map span:nth-child(2),
.path-map span:nth-child(3) {
border-color: var(--amber);
}
.path-map span:nth-child(4) {
border-color: var(--coral);
}
.path-map span:nth-child(5) {
border-color: var(--teal);
} }
.domain-grid { .domain-grid {
@@ -478,11 +577,33 @@ h3 {
} }
.domain-grid, .domain-grid,
.topology-grid, .topology-grid {
.transport-flow-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.map-theatre-controls,
.protocol-map {
grid-template-columns: 1fr;
}
.map-theatre {
min-height: 680px;
}
.path-map {
grid-template-columns: 1fr;
}
.path-map span:not(:last-child)::after {
top: auto;
right: auto;
bottom: -13px;
left: 50%;
width: 2px;
height: 13px;
transform: translateX(-50%);
}
.flow-list { .flow-list {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -517,6 +638,16 @@ h3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.map-theatre {
min-height: 760px;
}
.map-theatre-controls {
left: 12px;
right: 12px;
bottom: 12px;
}
.flow-list li { .flow-list li {
min-height: 0; min-height: 0;
} }