diff --git a/assets/scss/_404.scss b/assets/scss/_404.scss new file mode 100644 index 00000000..c52a8de2 --- /dev/null +++ b/assets/scss/_404.scss @@ -0,0 +1,537 @@ +$_err: $danger; +$_err-dim: rgba($danger, 0.35); + +body.is-404 { + padding-top: 0; + background: #0f1214; + + main { + max-width: none; + margin: 0; + padding: 0; + } + + .scroll-ambient, + .scroll-piece-anchor { + display: none !important; + } +} + +.error404-lab { + min-height: 100vh; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 7rem 1.2rem 3.5rem; + position: relative; + overflow: hidden; + box-sizing: border-box; +} + +.error404-atmosphere { + position: absolute; + inset: 0; + z-index: 0; + background: + radial-gradient(circle at 16% 12%, rgba($primary, 0.2), rgba($primary, 0) 42%), + radial-gradient(circle at 84% 80%, rgba($secondary, 0.16), rgba($secondary, 0) 46%), + radial-gradient(circle at 52% 50%, rgba($white, 0.02), rgba($white, 0) 52%), + linear-gradient(180deg, #0f1214 0%, #121212 52%, #0e1112 100%); + animation: auroraShift 14s ease-in-out infinite alternate; +} + +@keyframes auroraShift { + 0% { opacity: 1; } + 100% { opacity: 0.92; } +} + +.error404-grid { + position: absolute; + inset: -40px; + opacity: 0.07; + background-image: radial-gradient(rgba($secondary, 0.65) 0.8px, transparent 0.8px); + background-size: 28px 28px; + will-change: transform; + transition: transform 0.08s linear; +} + +.error404-grain { + position: absolute; + inset: 0; + opacity: 0.04; + background-image: radial-gradient(rgba($white, 0.5) 0.55px, transparent 0.55px); + background-size: 3px 3px; +} + +.error404-content { + width: 100%; + max-width: 980px; + position: relative; + z-index: 2; + text-align: center; +} + +.error404-status { + margin: 0 0 1.8rem; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba($white, 0.28); + font-family: $font-family-monospace, monospace; +} + +.error404-topology-wrap { + margin: 1.35rem auto 0; + width: 100%; + max-width: 980px; + position: relative; +} + +.error404-topology { + display: block; + width: 100%; + height: auto; + border-radius: 10px; + cursor: grab; + touch-action: none; + filter: drop-shadow(0 0 22px rgba($secondary, 0.08)); + + &:active { + cursor: grabbing; + } +} + +.edge { + fill: none; +} + +.edge-active { + stroke: rgba($secondary, 0.72); + stroke-width: 2; + animation: pulseActive 2.4s ease-in-out infinite; +} + +@keyframes pulseActive { + 0%, 100% { opacity: 0.52; } + 50% { opacity: 1; } +} + +.edge-muted { + stroke: rgba($white, 0.18); + stroke-width: 1.5; +} + +.edge-broken { + stroke: rgba($_err, 0.72); + stroke-width: 2; + stroke-dasharray: 8 5; + animation: marchBroken 1.3s linear infinite; +} + +@keyframes marchBroken { + to { stroke-dashoffset: -26; } +} + +.edge-reconnect { + stroke: url(#edgeGrad); + stroke-width: 2.5; + animation: pulseRecon 0.9s ease-in-out infinite alternate; +} + +@keyframes pulseRecon { + 0% { opacity: 0.55; } + 100% { opacity: 1; } +} + +.edge-hidden { + display: none; +} + +.node { + fill: rgba($white, 0.94); + stroke: rgba($white, 0.12); + stroke-width: 1; +} + +.node-entry { + fill: rgba($secondary, 0.92); +} + +.node-hub { + fill: rgba($secondary, 0.65); +} + +.node-home { + fill: rgba($secondary, 0.92); +} + +.node-missing { + fill: rgba($_err, 0.88); + stroke: rgba($_err, 0.45); + stroke-width: 1.5; + animation: missingFlicker 1.9s ease-in-out infinite; +} + +@keyframes missingFlicker { + 0%, 100% { fill: rgba($_err, 0.82); } + 48% { fill: rgba($_err, 1); } + 52% { fill: rgba($_err, 0.7); } +} + +.node-missing-ring { + fill: none; + stroke: $_err-dim; + stroke-width: 1.5; + stroke-dasharray: 6 4; + animation: ringOrbit 2.2s linear infinite; +} + +@keyframes ringOrbit { + to { stroke-dashoffset: -40; } +} + +.node-home-attract .node-home { + stroke: rgba($secondary, 0.8); + stroke-width: 3; +} + +.node-group-missing { + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +.node-label { + fill: rgba($white, 0.8); + text-anchor: middle; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + pointer-events: none; + user-select: none; +} + +.node-label-missing { + fill: rgba($_err, 0.92); + font-size: 12px; + letter-spacing: 0.06em; +} + +.packet { + fill: rgba($secondary, 0.96); + filter: drop-shadow(0 0 6px rgba($secondary, 0.78)); +} + +.packet-b { + fill: rgba($primary, 0.88); + filter: drop-shadow(0 0 5px rgba($primary, 0.68)); +} + +.packet-c { + fill: rgba($white, 0.65); + filter: none; +} + +.packet-drop { + fill: rgba($_err, 0.9); + filter: drop-shadow(0 0 6px rgba($_err, 0.75)); +} + +.error404-lab.is-reconnected { + .node-missing { + fill: rgba($secondary, 0.9); + animation: none; + stroke: rgba($secondary, 0.3); + } + + .node-missing-ring { + animation: none; + opacity: 0; + } + + .node-label-missing { + fill: rgba($white, 0.8); + } + + .edge-broken { + stroke: rgba($secondary, 0.72); + stroke-dasharray: none; + animation: pulseActive 2.4s ease-in-out infinite; + } + + .error404-insight-headline { + background: linear-gradient(135deg, $secondary 0%, rgba($primary, 0.9) 100%); + -webkit-background-clip: text; + background-clip: text; + } + + .error404-insight-copy { + color: rgba($white, 0.55); + } + + .error404-cta { + box-shadow: 0 0 28px rgba($secondary, 0.45); + animation: ctaPulse 1.4s ease-in-out infinite alternate; + } +} + +@keyframes ctaPulse { + 0% { box-shadow: 0 0 18px rgba($secondary, 0.35); } + 100% { box-shadow: 0 0 36px rgba($secondary, 0.6); } +} + +.error404-hint { + margin: 0.65rem 0 0; + font-size: 0.79rem; + color: rgba($white, 0.48); + letter-spacing: 0.01em; + transition: color 0.3s ease; + + .hint-em { + color: rgba($_err, 0.82); + font-weight: 700; + } + + a { + color: $secondary; + text-decoration: underline; + text-decoration-color: rgba($secondary, 0.5); + + &:hover { + color: $primary; + } + } +} + +.error404-insight { + margin: 0 auto 2.4rem; + max-width: 660px; + width: 100%; + display: flex; + flex-direction: column; +} + +.insight-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.insight-live { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba($secondary, 0.75); + font-family: $font-family-monospace, monospace; + flex-shrink: 0; +} + +.insight-dot { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: $secondary; + flex-shrink: 0; + animation: insightPulse 2s ease-out infinite; +} + +@keyframes insightPulse { + 0% { box-shadow: 0 0 0 0 rgba($secondary, 0.6); } + 70% { box-shadow: 0 0 0 7px rgba($secondary, 0); } + 100% { box-shadow: 0 0 0 0 rgba($secondary, 0); } +} + +.insight-rule { + flex: 1; + height: 1px; + background: rgba($white, 0.08); +} + +.insight-counter { + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.1em; + color: rgba($white, 0.25); + font-family: $font-family-monospace, monospace; + flex-shrink: 0; +} + +.insight-body { + display: flex; + flex-direction: column; + gap: 0.7rem; + min-height: 7.5rem; +} + +.error404-insight-headline { + margin: 0; + color: transparent; + background: linear-gradient(135deg, $white 20%, rgba($secondary, 0.88) 100%); + -webkit-background-clip: text; + background-clip: text; + font-size: clamp(1.8rem, 3.6vw, 2.6rem); + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.035em; + transition: opacity 0.18s ease; +} + +.error404-insight-copy { + margin: 0; + color: rgba($white, 0.48); + font-size: clamp(0.9rem, 1.5vw, 1.02rem); + font-weight: 400; + line-height: 1.6; + transition: opacity 0.22s ease; +} + +.insight-cursor { + display: inline-block; + width: 2px; + height: 0.9em; + background: $secondary; + border-radius: 1px; + margin-left: 3px; + animation: cursorBlink 1.1s step-end infinite; + vertical-align: baseline; + position: relative; + top: 1px; +} + +@keyframes cursorBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.insight-footer { + display: flex; + align-items: center; + gap: 1.2rem; + margin-top: 1.4rem; +} + +.error404-insight-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + font-weight: 600; + color: rgba($secondary, 0.85); + text-decoration: none; + letter-spacing: 0.02em; + flex-shrink: 0; + transition: color 0.18s ease, gap 0.18s ease; + + svg { + transition: transform 0.18s ease; + opacity: 0.7; + } + + &:hover { + color: $white; + gap: 0.6rem; + + svg { + transform: translate(2px, -2px); + opacity: 1; + } + } +} + +.insight-progress { + flex: 1; + height: 1px; + background: rgba($white, 0.07); + border-radius: 1px; + overflow: hidden; +} + +.insight-progress-bar { + display: block; + height: 100%; + width: 0%; + background: linear-gradient(90deg, rgba($secondary, 0.6), rgba($secondary, 0.2)); + border-radius: 1px; + transition: none; +} + +.error404-actions { + margin-top: 1.25rem; + display: flex; + justify-content: center; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} + +.error404-cta, +.error404-issue { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 42px; + line-height: 1; + padding: 0 1.3rem; + text-decoration: none; + font-size: 0.84rem; + border-radius: 999px; + white-space: nowrap; + vertical-align: middle; + transform: none; +} + +.error404-cta { + font-weight: 700; + background: linear-gradient(130deg, $secondary, $primary); + color: $dark; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 9px 24px rgba($primary, 0.32); + } +} + +.error404-issue { + font-weight: 600; + border: 1px solid rgba($white, 0.17); + color: rgba($white, 0.68); + transition: border-color 0.2s ease, color 0.2s ease; + + &:hover { + border-color: rgba($secondary, 0.6); + color: $secondary; + } +} + +@media (max-width: 760px) { + .error404-topology-wrap, + .error404-insight { + max-width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .edge-broken, + .edge-active, + .edge-reconnect, + .error404-atmosphere, + .node-missing, + .node-missing-ring, + .error404-cta, + .error404-insight-copy { + animation: none !important; + transition: none !important; + } +} diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index 889498dd..9b12f327 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -19,6 +19,7 @@ @import "collaboration"; @import "timeline"; @import "feedback"; +@import "404"; @import "section-transitions"; @import "scroll-cube"; diff --git a/layouts/404.html b/layouts/404.html new file mode 100644 index 00000000..bab042da --- /dev/null +++ b/layouts/404.html @@ -0,0 +1,136 @@ +{{ define "main" }} + + + + + + + + + + + 404 — Page not found + + + + + + + Live insight + + + 01 / 08 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ingress + + + + auth + + + + gateway + + + + catalog + + + + policy + + + + mesh + + + + home + + + + + + 404 + + + + + + + + + + Drag the broken node onto home to restore the route + + + + + Return to Kanvas + Report broken route + + + + + +{{ end }} diff --git a/static/scripts/404.js b/static/scripts/404.js new file mode 100644 index 00000000..0f5e8d05 --- /dev/null +++ b/static/scripts/404.js @@ -0,0 +1,596 @@ +"use strict"; + +function init() { + var lab = document.querySelector(".error404-lab"); + if (!lab) return; + + var topologySvg = document.getElementById("topologySvg"); + var reconnectHint = document.getElementById("reconnectHint"); + var missingNodeLabel = document.getElementById("missingNodeLabel"); + var reconnectEdge = document.getElementById("edge-miss-home"); + var brokenEdge = document.getElementById("edge-gw-missing"); + var bgGrid = document.getElementById("bgGrid"); + + var insightHeadlineEl = document.getElementById("insightHeadline"); + var insightCopyEl = document.getElementById("insightCopy"); + var insightLinkEl = document.getElementById("insightLink"); + var insightLinkText = document.getElementById("insightLinkText"); + var insightCounter = document.getElementById("insightCounter"); + var insightProgressBar = document.getElementById("insightProgressBar"); + + var routePath = window.location.pathname || "/"; + var shortPath = + routePath.length > 16 ? routePath.slice(0, 13) + "..." : routePath; + var labelText = routePath === "/" || routePath === "" ? "404" : shortPath; + + if (missingNodeLabel) missingNodeLabel.textContent = labelText; + + var insights = [ + { + headline: "No YAML. Just your mesh.", + copy: "Drag, drop, and wire entire service topologies visually — Kanvas turns infra into something you can actually see.", + href: "https://kanvas.new", + cta: "Try Kanvas Designer", + }, + { + headline: "Your cluster, made visible.", + copy: "Kanvas renders live Kubernetes topology in real-time, pulling state directly from your cluster.", + href: "https://docs.kanvas.new", + cta: "See how it works", + }, + { + headline: "Infra design is a team sport.", + copy: "Multiple engineers can edit the same topology at the same time — like Figma, but for Kubernetes.", + href: "https://kanvas.new", + cta: "Explore collaboration", + }, + { + headline: "220+ integrations. One canvas.", + copy: "Prometheus, Argo, Istio, Linkerd — if it runs in your cluster, Kanvas can map it.", + href: "https://layer5.io/cloud-native-management/meshery/integrations", + cta: "View integrations", + }, + { + headline: "Design is the source of truth.", + copy: "Every change in Kanvas syncs directly to cluster state — your diagram is never out of date.", + href: "https://docs.kanvas.new", + cta: "Read the docs", + }, + { + headline: "CNCF-backed. Production-ready.", + copy: "Kanvas is built on Meshery, the CNCF-hosted lifecycle manager trusted by thousands of engineers.", + href: "https://layer5.io/cloud-native-management/kanvas", + cta: "Learn about Meshery", + }, + { + headline: "Reuse. Don't reinvent.", + copy: "Kanvas has a built-in catalog of battle-tested design patterns your whole team can fork and deploy.", + href: "https://cloud.layer5.io/catalog", + cta: "Open the catalog", + }, + { + headline: "Every drag is a GitOps event.", + copy: "Topology changes in Kanvas Designer are git-trackable — full audit trail, zero extra tooling.", + href: "https://docs.kanvas.new", + cta: "Explore GitOps support", + }, + ]; + + var lastInsight = null; + var insightIndex = 0; + var typeTimer = null; + var CYCLE_MS = 9000; + + function pickInsight() { + if (insights.length <= 1) return insights[0]; + var item; + do { + item = insights[Math.floor(Math.random() * insights.length)]; + } while (item === lastInsight); + return item; + } + + function updateCounter(idx) { + if (!insightCounter) return; + var n = String(idx + 1).padStart(2, "0"); + var tot = String(insights.length).padStart(2, "0"); + insightCounter.textContent = n + " / " + tot; + insightCounter.setAttribute( + "aria-label", + "Insight " + (idx + 1) + " of " + insights.length, + ); + } + + function startProgress() { + if (!insightProgressBar) return; + insightProgressBar.style.transition = "none"; + insightProgressBar.style.width = "0%"; + insightProgressBar.offsetWidth; + insightProgressBar.style.transition = + "width " + CYCLE_MS / 1000 + "s linear"; + insightProgressBar.style.width = "100%"; + } + + function typeText(el, text, speed, onDone) { + if (!el) { + if (onDone) onDone(); + return; + } + clearTimeout(typeTimer); + el.textContent = ""; + var i = 0; + function tick() { + if (i < text.length) { + el.textContent += text[i++]; + typeTimer = setTimeout(tick, speed); + } else if (onDone) { + onDone(); + } + } + tick(); + } + + function setInsight(animate) { + var next = pickInsight(); + lastInsight = next; + insightIndex = insights.indexOf(next); + + if (!insightCopyEl || !insightLinkEl) return; + + if (insightLinkText) insightLinkText.textContent = next.cta; + insightLinkEl.href = next.href; + updateCounter(insightIndex); + + function show() { + insightHeadlineEl && (insightHeadlineEl.style.opacity = "1"); + insightCopyEl && (insightCopyEl.style.opacity = "0"); + + typeText(insightHeadlineEl, next.headline, 28, function () { + setTimeout(function () { + insightCopyEl.textContent = next.copy; + insightCopyEl.style.opacity = "1"; + startProgress(); + }, 120); + }); + } + + if (animate) { + insightHeadlineEl && (insightHeadlineEl.style.opacity = "0"); + insightCopyEl && (insightCopyEl.style.opacity = "0"); + setTimeout(show, 200); + } else { + show(); + } + } + + function runEntrance() { + if (typeof gsap === "undefined") return; + gsap + .timeline({ defaults: { ease: "power2.out" } }) + .from(".error404-insight", { + y: 24, + opacity: 0, + duration: 0.65, + scale: 0.97, + }) + .from( + ".error404-topology-wrap", + { y: 20, opacity: 0, duration: 0.55 }, + "-=0.22", + ) + .from(".error404-actions", { opacity: 0, duration: 0.4 }, "-=0.15"); + } + + function onMouseParallax(e) { + if (!bgGrid) return; + var cx = window.innerWidth / 2; + var cy = window.innerHeight / 2; + var dx = (e.clientX - cx) / cx; + var dy = (e.clientY - cy) / cy; + bgGrid.style.transform = + "translate(" + (dx * 9).toFixed(1) + "px, " + (dy * 7).toFixed(1) + "px)"; + } + + if (!topologySvg) { + setInsight(false); + setInterval(function () { + setInsight(true); + }, CYCLE_MS); + runEntrance(); + document.addEventListener("mousemove", onMouseParallax); + return; + } + + var VBW = 860; + var VBH = 320; + + var BASE = { + ingress: { x: 70, y: 185, pinned: true }, + auth: { x: 210, y: 185 }, + gateway: { x: 370, y: 150 }, + catalog: { x: 370, y: 250 }, + policy: { x: 530, y: 120 }, + mesh: { x: 530, y: 205 }, + home: { x: 700, y: 185, pinned: true }, + missing: { x: 530, y: 58 }, + }; + + var state = {}; + Object.keys(BASE).forEach(function (k) { + var b = BASE[k]; + state[k] = { + x: b.x, + y: b.y, + baseX: b.x, + baseY: b.y, + vx: 0, + vy: 0, + pinned: !!b.pinned, + }; + }); + + var nodeCircles = {}; + var nodeLabels = {}; + var nodeHomeG = document.getElementById("node-home"); + + Object.keys(BASE).forEach(function (k) { + var g = document.querySelector("#node-" + k); + if (!g) return; + nodeCircles[k] = g.querySelector("circle.node"); + nodeLabels[k] = g.querySelector("text"); + if (k === "missing") { + nodeCircles["missing-ring"] = g.querySelector(".node-missing-ring"); + } + }); + + var edgeDefs = [ + ["edge-ing-auth", "ingress", "auth"], + ["edge-auth-gw", "auth", "gateway"], + ["edge-gw-pol", "gateway", "policy"], + ["edge-pol-home", "policy", "home"], + ["edge-auth-cat", "auth", "catalog"], + ["edge-cat-mesh", "catalog", "mesh"], + ["edge-mesh-home", "mesh", "home"], + ["edge-gw-cat", "gateway", "catalog"], + ["edge-gw-missing", "gateway", "missing"], + ["edge-miss-home", "missing", "home"], + ]; + + var edgeEls = {}; + edgeDefs.forEach(function (d) { + edgeEls[d[0]] = document.getElementById(d[0]); + }); + + var packetA = document.getElementById("packetA"); + var packetB = document.getElementById("packetB"); + var packetC = document.getElementById("packetC"); + var packetDrop = document.getElementById("packetDrop"); + + var pointer = { x: -9999, y: -9999 }; + var dragging = null; + var dragLastX = 0; + var dragLastY = 0; + var burst = 0; + var raf = 0; + var t = 0; + var ticks = 0; + var reconnected = false; + + function svgXY(e) { + var rect = topologySvg.getBoundingClientRect(); + return { + x: (e.clientX - rect.left) * (VBW / rect.width), + y: (e.clientY - rect.top) * (VBH / rect.height), + }; + } + + function stirTopology(power) { + var p = power || 1; + burst = Math.max(burst, 1.1 * p); + Object.keys(BASE).forEach(function (k) { + var n = state[k]; + if (n.pinned) return; + n.vx += (Math.random() - 0.5) * 3.2 * p; + n.vy += (Math.random() - 0.5) * 3.2 * p; + }); + } + + function interpolate(keys, p) { + var clamped = Math.max(0, Math.min(0.999, p)); + var seg = (keys.length - 1) * clamped; + var i = Math.floor(seg); + var f = seg - i; + var a = state[keys[i]]; + var b = state[keys[i + 1]]; + return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f }; + } + + var pathA = ["ingress", "auth", "gateway", "policy", "home"]; + var pathB = ["auth", "catalog", "mesh", "home"]; + var pathC = ["gateway", "policy"]; + var pathRecon = ["ingress", "auth", "gateway", "missing", "home"]; + + function paint() { + edgeDefs.forEach(function (d) { + var e = edgeEls[d[0]]; + if (!e) return; + var a = state[d[1]]; + var b = state[d[2]]; + e.setAttribute("x1", a.x.toFixed(1)); + e.setAttribute("y1", a.y.toFixed(1)); + e.setAttribute("x2", b.x.toFixed(1)); + e.setAttribute("y2", b.y.toFixed(1)); + }); + + Object.keys(BASE).forEach(function (k) { + var n = state[k]; + var c = nodeCircles[k]; + var l = nodeLabels[k]; + var ring = k === "missing" ? nodeCircles["missing-ring"] : null; + var above = k === "gateway" || k === "policy" || k === "missing"; + + if (c) { + c.setAttribute("cx", n.x.toFixed(1)); + c.setAttribute("cy", n.y.toFixed(1)); + } + if (ring) { + ring.setAttribute("cx", n.x.toFixed(1)); + ring.setAttribute("cy", n.y.toFixed(1)); + } + if (l) { + l.setAttribute("x", n.x.toFixed(1)); + l.setAttribute("y", (n.y + (above ? -25 : 27)).toFixed(1)); + } + }); + } + + function reconnect() { + reconnected = true; + + var m = state.missing; + m.x = 570; + m.y = 90; + m.baseX = m.x; + m.baseY = m.y; + m.vx = 0; + m.vy = 0; + m.pinned = true; + + if (reconnectEdge) reconnectEdge.classList.remove("edge-hidden"); + if (brokenEdge) { + brokenEdge.classList.remove("edge-broken"); + brokenEdge.classList.add("edge-active"); + } + + var mc = nodeCircles["missing"]; + if (mc) { + mc.classList.remove("node-missing"); + mc.classList.add("node-hub"); + } + + var ring = nodeCircles["missing-ring"]; + if (ring) ring.style.display = "none"; + + if (missingNodeLabel) { + missingNodeLabel.classList.remove("node-label-missing"); + missingNodeLabel.textContent = labelText; + } + + if (packetDrop) packetDrop.setAttribute("opacity", "0"); + + lab.classList.add("is-reconnected"); + + clearTimeout(typeTimer); + if (insightHeadlineEl) insightHeadlineEl.textContent = "Route restored."; + if (insightCopyEl) insightCopyEl.textContent = "Taking you home\u2026"; + if (insightLinkEl) insightLinkEl.style.display = "none"; + if (insightProgressBar) { + insightProgressBar.style.transition = "none"; + insightProgressBar.style.width = "100%"; + insightProgressBar.style.background = "#00d3a9"; + } + + if (reconnectHint) + reconnectHint.textContent = "Topology healed \u2014 redirecting\u2026"; + + stirTopology(1.1); + + setTimeout(function () { + window.location.href = "/"; + }, 1800); + } + + function loop() { + t += 0.0085; + ticks += 1; + if (ticks % 300 === 0) stirTopology(0.58); + + Object.keys(BASE).forEach(function (k) { + var n = state[k]; + if (n.pinned || dragging === k) return; + + var dx = pointer.x - n.x; + var dy = pointer.y - n.y; + var d2 = dx * dx + dy * dy; + if (d2 < 28000) { + var f = (28000 - d2) / 28000; + n.vx -= (dx / 1200) * f; + n.vy -= (dy / 1200) * f; + } + + n.vx += (n.baseX - n.x) * 0.007; + n.vy += (n.baseY - n.y) * 0.007; + + if (burst > 0.001) { + var bx = n.x - pointer.x; + var by = n.y - pointer.y; + var bl = Math.sqrt(bx * bx + by * by) || 1; + n.vx += (bx / bl) * burst * 0.34; + n.vy += (by / bl) * burst * 0.34; + } + + n.vx *= 0.925; + n.vy *= 0.925; + n.x += n.vx; + n.y += n.vy; + }); + + burst *= 0.88; + paint(); + + if (packetA) { + var pA = interpolate( + reconnected ? pathRecon : pathA, + Math.sin(t * 1.9) * 0.5 + 0.5, + ); + packetA.setAttribute("cx", pA.x.toFixed(1)); + packetA.setAttribute("cy", pA.y.toFixed(1)); + packetA.setAttribute("r", (3.6 + Math.sin(t * 7) * 1.1).toFixed(1)); + } + + if (packetB) { + var pB = interpolate(pathB, Math.sin(t * 1.52 + 2.1) * 0.5 + 0.5); + packetB.setAttribute("cx", pB.x.toFixed(1)); + packetB.setAttribute("cy", pB.y.toFixed(1)); + packetB.setAttribute("r", (3.1 + Math.cos(t * 6.1) * 0.9).toFixed(1)); + } + + if (packetC) { + var pC = interpolate(pathC, Math.sin(t * 2.8 + 1.0) * 0.5 + 0.5); + packetC.setAttribute("cx", pC.x.toFixed(1)); + packetC.setAttribute("cy", pC.y.toFixed(1)); + } + + if (packetDrop && !reconnected) { + var cycle = (t * 0.52) % 1; + var progress = cycle < 0.6 ? cycle / 0.6 : 1; + var alpha = cycle < 0.5 ? 1 : Math.max(0, 1 - (cycle - 0.5) / 0.12); + var gw = state.gateway; + var ms = state.missing; + packetDrop.setAttribute( + "cx", + (gw.x + (ms.x - gw.x) * progress).toFixed(1), + ); + packetDrop.setAttribute( + "cy", + (gw.y + (ms.y - gw.y) * progress).toFixed(1), + ); + packetDrop.setAttribute("opacity", alpha.toFixed(2)); + } + + raf = requestAnimationFrame(loop); + } + + function onPointerMove(e) { + var p = svgXY(e); + pointer.x = p.x; + pointer.y = p.y; + + if (dragging) { + var n = state[dragging]; + var nx = Math.max(20, Math.min(840, p.x)); + var ny = Math.max(20, Math.min(300, p.y)); + n.vx = (nx - n.x) * 0.42; + n.vy = (ny - n.y) * 0.42; + n.x = nx; + n.y = ny; + dragLastX = nx; + dragLastY = ny; + topologySvg.style.cursor = "grabbing"; + + if (dragging === "missing" && nodeHomeG) { + var h = state.home; + var ddx = n.x - h.x; + var ddy = n.y - h.y; + if (Math.sqrt(ddx * ddx + ddy * ddy) < 90) { + nodeHomeG.classList.add("node-home-attract"); + if (reconnectHint) + reconnectHint.textContent = "Release to reconnect!"; + } else { + nodeHomeG.classList.remove("node-home-attract"); + if (reconnectHint) { + reconnectHint.innerHTML = + 'Drag the broken node onto home to restore the route'; + } + } + } + } + } + + function onPointerLeave() { + pointer.x = -9999; + pointer.y = -9999; + } + + function onPointerDown(e) { + var p = svgXY(e); + pointer.x = p.x; + pointer.y = p.y; + + var keys = Object.keys(BASE); + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + var n = state[k]; + if (n.pinned) continue; + var dx = n.x - p.x; + var dy = n.y - p.y; + if (Math.sqrt(dx * dx + dy * dy) < 22) { + dragging = k; + dragLastX = n.x; + dragLastY = n.y; + topologySvg.style.cursor = "grabbing"; + break; + } + } + + if (!dragging) stirTopology(1.0); + } + + function onPointerUp() { + if (!dragging) return; + var n = state[dragging]; + n.vx += (n.x - dragLastX) * 0.06; + n.vy += (n.y - dragLastY) * 0.06; + + if (dragging === "missing" && !reconnected) { + var h = state.home; + var dx = n.x - h.x; + var dy = n.y - h.y; + if (Math.sqrt(dx * dx + dy * dy) < 65) { + reconnect(); + } else { + if (nodeHomeG) nodeHomeG.classList.remove("node-home-attract"); + if (reconnectHint) { + reconnectHint.innerHTML = + 'Drag the broken node onto home to restore the route'; + } + } + } + + dragging = null; + topologySvg.style.cursor = "grab"; + } + + topologySvg.addEventListener("pointermove", onPointerMove); + topologySvg.addEventListener("pointerleave", onPointerLeave); + topologySvg.addEventListener("pointerdown", onPointerDown); + topologySvg.addEventListener("pointerup", onPointerUp); + document.addEventListener("mousemove", onMouseParallax); + + setInsight(false); + setInterval(function () { + setInsight(true); + }, CYCLE_MS); + runEntrance(); + paint(); + loop(); + + window.addEventListener("pagehide", function () { + if (raf) { + cancelAnimationFrame(raf); + } + document.removeEventListener("mousemove", onMouseParallax); + }); +} + +init();
404 — Page not found
+ Drag the broken node onto home to restore the route +