This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 357f39759dda CAMEL-23636: Add camel-route-diagram web component
357f39759dda is described below
commit 357f39759dda137b0d95b80f32feee0288411480
Author: Adriano Machado <[email protected]>
AuthorDate: Fri Jun 19 08:35:15 2026 -0400
CAMEL-23636: Add camel-route-diagram web component
Add a <camel-route-diagram> vanilla Web Component to the camel-diagram
module that renders interactive SVG route diagrams in the browser. The
component is a single self-contained JS file with no npm/Node.js
dependency, served as a static resource from META-INF/resources. It
features a JS port of RouteDiagramLayoutEngine, CSS custom-property
theming with automatic dark mode, Lucide icons (ISC), ARIA accessibility,
and AbortController-based fetch cancellation. Also consolidates the
duplicate wrapText logic from RouteDiagramAsciiRenderer and
TopologyAsciiRenderer into RouteDiagramHelper.
Closes #24064
Co-Authored-By: Claude <[email protected]>
---
components/camel-diagram/pom.xml | 24 +-
.../camel-diagram/src/main/docs/diagram.adoc | 69 ++
.../camel/diagram/RouteDiagramAsciiRenderer.java | 47 +-
.../apache/camel/diagram/RouteDiagramHelper.java | 68 +-
.../apache/camel/diagram/RouteDiagramRenderer.java | 6 +-
.../camel/diagram/TopologyAsciiRenderer.java | 47 +-
.../camel/diagram/THIRD-PARTY-NOTICES.txt | 23 +
.../resources/camel/diagram/camel-route-diagram.js | 492 ++++++++++++
.../camel/diagram/RouteDiagramHelperTest.java | 55 ++
.../diagram/RouteDiagramLayoutEngineTest.java | 234 ++++++
.../org/apache/camel/diagram/RouteDiagramTest.java | 6 +-
.../apache/camel/diagram/TopologyDiagramTest.java | 10 -
.../camel/diagram/WebComponentBundleTest.java | 91 +++
.../src/test/resources/integration-test.html | 862 +++++++++++++++++++++
.../src/test/resources/smoke-test.html | 720 +++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_21.adoc | 17 +-
16 files changed, 2652 insertions(+), 119 deletions(-)
diff --git a/components/camel-diagram/pom.xml b/components/camel-diagram/pom.xml
index e25ac06f9f72..f216e2b81ada 100644
--- a/components/camel-diagram/pom.xml
+++ b/components/camel-diagram/pom.xml
@@ -17,7 +17,8 @@
limitations under the License.
-->
-<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
@@ -99,7 +100,26 @@
<scope>test</scope>
</dependency>
-
</dependencies>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.mycila</groupId>
+ <artifactId>license-maven-plugin</artifactId>
+ <configuration>
+ <licenseSets>
+ <licenseSet>
+ <excludes combine.children="append">
+ <!-- third-party attribution notice (plain
text, no license header) -->
+
<exclude>src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
+ </exclude>
+ </excludes>
+ </licenseSet>
+ </licenseSets>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
</project>
diff --git a/components/camel-diagram/src/main/docs/diagram.adoc
b/components/camel-diagram/src/main/docs/diagram.adoc
index f624170dfa06..a7cde4eb2f5e 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -276,3 +276,72 @@ String diagram = renderer.renderDiagramAnsi(layoutRoutes,
totalHeight, highlight
RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(nodeWidth,
fontSize);
BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight,
colors, highlightedNodes, style);
----
+
+== Embeddable Web Component
+
+`camel-diagram` ships a lightweight `<camel-route-diagram>` web component that
renders
+interactive route diagrams as SVG directly in the browser.
+Any application with `camel-diagram` on the classpath automatically serves the
component
+as a static resource — no extra server configuration needed.
+
+=== Usage
+
+Include the bundled script served from
`META-INF/resources/camel/diagram/camel-route-diagram.js`
+(automatically exposed by Servlet 3 containers and Quarkus/Spring Boot
static-resource mechanisms):
+
+[source,html]
+----
+<script type="module" src="/camel/diagram/camel-route-diagram.js"></script>
+
+<camel-route-diagram
+ src="/q/dev/route-structure"
+ refresh="5000"
+ filter="my-route">
+</camel-route-diagram>
+----
+
+The `src` attribute must point to an endpoint returning the `route-structure`
dev console JSON
+(for example the Quarkus Dev UI endpoint `/q/dev/route-structure`).
+The component automatically appends `?metric=true` so that per-processor
exchange statistics
+are included in the diagram.
+
+=== Attributes
+
+[width="100%",cols="2,5,2",options="header"]
+|===
+| Attribute | Description | Default
+| `src` | URL to fetch the route-structure JSON from (required) | —
+| `refresh` | Polling interval in milliseconds; `0` disables polling | `0`
+| `filter` | Route ID filter, forwarded as `?filter=` query parameter | (all
routes)
+|===
+
+=== Theming
+
+The component is theme-agnostic.
+It respects `prefers-color-scheme` automatically for dark/light mode,
+and exposes CSS custom properties so the host application can override every
visual aspect:
+
+[source,css]
+----
+camel-route-diagram {
+ --crd-bg: #ffffff; /* canvas background */
+ --crd-fg: #1e293b; /* text colour */
+ --crd-edge: #94a3b8; /* edge/arrow colour */
+ --crd-stat: #64748b; /* metric overlay text */
+ --crd-font: system-ui; /* font family */
+ --crd-font-size: 12px; /* base font size */
+ --crd-color-route: #6366f1; /* "route" node */
+ --crd-color-from: #0ea5e9; /* "from" node */
+ --crd-color-to: #0ea5e9; /* "to" node */
+ --crd-color-log: #64748b; /* "log" node */
+ --crd-color-choice: #f59e0b; /* "choice" node */
+ --crd-color-when: #fbbf24; /* "when" branch */
+ --crd-color-otherwise: #fbbf24; /* "otherwise" branch */
+ --crd-color-doTry: #f59e0b; /* "doTry" scope */
+ --crd-color-doCatch: #fbbf24; /* "doCatch" clause */
+ --crd-color-doFinally: #fbbf24; /* "doFinally" clause */
+ --crd-color-multicast: #8b5cf6; /* "multicast" node */
+ --crd-color-circuitBreaker: #ef4444; /* "circuitBreaker" node */
+ --crd-color-default: #6366f1; /* all other EIP nodes */
+}
+----
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
index d32e850300c0..b3a30dafb103 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
@@ -36,7 +36,6 @@ import static
org.apache.camel.diagram.RouteDiagramLayoutEngine.SCOPE_BOX_PAD;
*/
public class RouteDiagramAsciiRenderer {
- private static final int MAX_WRAP_LINES = 3;
private static final int Y_SCALE = 20;
private static final int MIN_BOX_WIDTH = 16;
private static final int X_DIVISOR = 15;
@@ -484,51 +483,7 @@ public class RouteDiagramAsciiRenderer {
private List<String> rewrapText(LayoutNode node, int maxWidth) {
String label = String.join("", node.wrappedLines);
- return wrapText(label, maxWidth);
- }
-
- static List<String> wrapText(String text, int maxWidth) {
- if (maxWidth <= 0 || text.length() <= maxWidth) {
- return List.of(text);
- }
-
- List<String> lines = new ArrayList<>();
- String remaining = text;
-
- while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
- if (remaining.length() <= maxWidth) {
- lines.add(remaining);
- remaining = "";
- break;
- }
-
- int breakAt = -1;
- for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
- char c = remaining.charAt(i);
- if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ','
|| c == '&' || c == '?') {
- breakAt = i + 1;
- }
- }
- if (breakAt <= 0) {
- breakAt = maxWidth;
- }
-
- lines.add(remaining.substring(0, breakAt).stripTrailing());
- remaining = remaining.substring(breakAt).stripLeading();
- }
-
- if (!remaining.isEmpty()) {
- int lastIdx = lines.size() - 1;
- String lastLine = lines.get(lastIdx);
- if (lastLine.length() + remaining.length() <= maxWidth) {
- lines.set(lastIdx, lastLine + remaining);
- } else {
- String combined = lastLine + remaining;
- lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth
- 3)) + "...");
- }
- }
-
- return lines;
+ return RouteDiagramHelper.wrapText(label, maxWidth);
}
private int toCol(int pixelX) {
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
index 1d298aa1f792..603b827594f7 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
@@ -36,6 +36,8 @@ import org.apache.camel.util.json.Jsoner;
*/
public final class RouteDiagramHelper {
+ static final int MAX_WRAP_LINES = 3;
+
private RouteDiagramHelper() {
}
@@ -52,12 +54,10 @@ public final class RouteDiagramHelper {
return routes;
}
- for (int i = 0; i < arr.size(); i++) {
- Object item = arr.get(i);
- if (!(item instanceof JsonObject)) {
+ for (Object item : arr) {
+ if (!(item instanceof JsonObject o)) {
continue;
}
- JsonObject o = (JsonObject) item;
RouteInfo route = new RouteInfo();
route.routeId = o.getString("routeId");
String source = o.getString("source");
@@ -99,13 +99,15 @@ public final class RouteDiagramHelper {
stat = new RouteDiagramLayoutEngine.StatInfo();
}
node.stat = stat;
- stat.idleSince = ls.getLong("idleSince");
- stat.exchangesTotal = ls.getLong("exchangesTotal");
- stat.exchangesFailed = ls.getLong("exchangesFailed");
- stat.exchangesInflight =
ls.getLong("exchangesInflight");
- stat.meanProcessingTime =
ls.getLong("meanProcessingTime");
- stat.maxProcessingTime =
ls.getLong("maxProcessingTime");
- stat.minProcessingTime =
ls.getLong("minProcessingTime");
+ // counters default to 0 so a partial statistics
object (missing a field) does not NPE on
+ // auto-unboxing into the primitive long fields of
StatInfo
+ stat.idleSince = ls.getLongOrDefault("idleSince", 0);
+ stat.exchangesTotal =
ls.getLongOrDefault("exchangesTotal", 0);
+ stat.exchangesFailed =
ls.getLongOrDefault("exchangesFailed", 0);
+ stat.exchangesInflight =
ls.getLongOrDefault("exchangesInflight", 0);
+ stat.meanProcessingTime =
ls.getLongOrDefault("meanProcessingTime", 0);
+ stat.maxProcessingTime =
ls.getLongOrDefault("maxProcessingTime", 0);
+ stat.minProcessingTime =
ls.getLongOrDefault("minProcessingTime", 0);
stat.lastProcessingTime =
ls.getLongOrDefault("lastProcessingTime", -1);
stat.deltaProcessingTime =
ls.getLongOrDefault("deltaProcessingTime", -1);
stat.lastCreatedExchangeTimestamp =
ls.getLongOrDefault("lastCreatedExchangeTimestamp", -1);
@@ -120,6 +122,50 @@ public final class RouteDiagramHelper {
return routes;
}
+ static List<String> wrapText(String text, int maxWidth) {
+ if (maxWidth <= 0 || text.length() <= maxWidth) {
+ return List.of(text);
+ }
+
+ List<String> lines = new ArrayList<>();
+ String remaining = text;
+
+ while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
+ if (remaining.length() <= maxWidth) {
+ lines.add(remaining);
+ remaining = "";
+ break;
+ }
+
+ int breakAt = -1;
+ for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
+ char c = remaining.charAt(i);
+ if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ','
|| c == '&' || c == '?') {
+ breakAt = i + 1;
+ }
+ }
+ if (breakAt <= 0) {
+ breakAt = maxWidth;
+ }
+
+ lines.add(remaining.substring(0, breakAt).stripTrailing());
+ remaining = remaining.substring(breakAt).stripLeading();
+ }
+
+ if (!remaining.isEmpty()) {
+ int lastIdx = lines.size() - 1;
+ String lastLine = lines.get(lastIdx);
+ if (lastLine.length() + remaining.length() <= maxWidth) {
+ lines.set(lastIdx, lastLine + remaining);
+ } else {
+ String combined = lastLine + remaining;
+ lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth
- 3)) + "...");
+ }
+ }
+
+ return lines;
+ }
+
public enum HighlightStyle {
SUCCESS,
FAIL
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
index 9da413e0ef0c..267c2e422b98 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
@@ -100,7 +100,7 @@ public class RouteDiagramRenderer {
public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, int
nodeTextPadding, boolean metrics) {
this.nodeWidth = nodeWidth;
this.fontSizeNode = fontSizeScaled;
- this.fontSizeLabel = fontSizeScaled + 1 * SCALE;
+ this.fontSizeLabel = fontSizeScaled + SCALE;
this.nodeTextPadding = nodeTextPadding;
this.metrics = metrics;
}
@@ -141,7 +141,7 @@ public class RouteDiagramRenderer {
c.text = parseColor(map.getOrDefault("text", "#ffffff"));
c.arrow = parseColor(map.getOrDefault("arrow", "#b4b4b4"));
c.counter = parseColor(map.getOrDefault("counter", "#2e7d32"));
- c.counterFail = parseColor(map.getOrDefault("counter", "#ff0000"));
+ c.counterFail = parseColor(map.getOrDefault("counterFail",
"#ff0000"));
c.routeLabel = parseColor(map.getOrDefault("label", "#c8c8c8"));
c.nodeFrom = parseColor(map.getOrDefault("from", "#2e7d32"));
c.nodeTo = parseColor(map.getOrDefault("to", "#1565c0"));
@@ -167,7 +167,7 @@ public class RouteDiagramRenderer {
}
Integer idx = Colors.rgbColor(value);
if (idx != null) {
- return new Color(Colors.rgbColor(idx.intValue()));
+ return new Color(Colors.rgbColor(idx));
}
return null;
}
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
index 9c1b6b9b3400..0803f642cc38 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
@@ -25,6 +25,8 @@ import
org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+import static org.apache.camel.diagram.RouteDiagramHelper.wrapText;
+
/**
* Renders topology diagrams as ASCII art or Unicode box-drawing text.
*/
@@ -33,7 +35,7 @@ public class TopologyAsciiRenderer {
private static final int Y_SCALE = 20;
private static final int MIN_BOX_WIDTH = 16;
private static final int X_DIVISOR = 15;
- private static final int MAX_WRAP_LINES = 3;
+ private static final int MAX_WRAP_LINES =
RouteDiagramHelper.MAX_WRAP_LINES;
private static final char UNI_H = '─';
private static final char UNI_V = '│';
@@ -151,8 +153,7 @@ public class TopologyAsciiRenderer {
line1 = node.routeId;
}
- List<String> lines = new ArrayList<>();
- lines.addAll(wrapText(line1, boxWidth - 4));
+ List<String> lines = new ArrayList<>(wrapText(line1, boxWidth - 4));
if (!isExternalNode(node) && !showDescription) {
String line2 = "(" + node.from + ")";
List<String> fromLines = wrapText(line2, boxWidth - 4);
@@ -331,46 +332,6 @@ public class TopologyAsciiRenderer {
return 2 + Math.min(lines, MAX_WRAP_LINES + 1);
}
- static List<String> wrapText(String text, int maxWidth) {
- if (maxWidth <= 0 || text.length() <= maxWidth) {
- return new ArrayList<>(List.of(text));
- }
-
- List<String> lines = new ArrayList<>();
- String remaining = text;
-
- while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
- if (remaining.length() <= maxWidth) {
- lines.add(remaining);
- remaining = "";
- break;
- }
-
- int breakAt = -1;
- for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
- char c = remaining.charAt(i);
- if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ','
|| c == '&' || c == '?') {
- breakAt = i + 1;
- }
- }
- if (breakAt <= 0) {
- breakAt = maxWidth;
- }
-
- lines.add(remaining.substring(0, breakAt).stripTrailing());
- remaining = remaining.substring(breakAt).stripLeading();
- }
-
- if (!remaining.isEmpty()) {
- int lastIdx = lines.size() - 1;
- String lastLine = lines.get(lastIdx);
- String combined = lastLine + remaining;
- lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth -
3)) + "...");
- }
-
- return lines;
- }
-
private String applyAnsiColors(String plain) {
if (counterPositions.isEmpty()) {
return plain;
diff --git
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
new file mode 100644
index 000000000000..1a892479e696
--- /dev/null
+++
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt
@@ -0,0 +1,23 @@
+camel-route-diagram bundles the following third-party content.
+
+--------------------------------------------------------------------------------
+Lucide (icon SVG paths inlined in camel-route-diagram.js):
+--------------------------------------------------------------------------------
+
+ ISC License
+ Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as
part of Feather (MIT).
+ All other copyright (c) for Lucide are held by Lucide Contributors 2022.
+ SPDX-License-Identifier: ISC
+ https://lucide.dev
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
copyright
+notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS
+OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+THIS SOFTWARE.
diff --git
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js
new file mode 100644
index 000000000000..869f075288d1
--- /dev/null
+++
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js
@@ -0,0 +1,492 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// ─── Layout engine (ported from RouteDiagramLayoutEngine.java)
───────────────
+
+const NODE_W = 180;
+const NODE_H = 36;
+const H_GAP = NODE_W / 2;
+const V_GAP = 40;
+const PADDING = 30;
+const ARROW_SIZE = 6;
+
+const BRANCHING_EIPS = new Set([
+ 'choice', 'multicast', 'doTry', 'loadBalance', 'recipientList',
'circuitBreaker',
+]);
+
+function buildTree(nodes) {
+ if (!nodes.length) return null;
+ const root = { info: nodes[0], children: [], parent: null, subtreeWidth: 0
};
+ let current = root;
+
+ for (let i = 1; i < nodes.length; i++) {
+ const ni = nodes[i];
+ if (!ni.id) {
+ console.warn('camel-route-diagram: node without an id is omitted
from the diagram', ni);
+ continue;
+ }
+ const tn = { info: ni, children: [], parent: null, subtreeWidth: 0 };
+
+ if (ni.level > current.info.level) {
+ current.children.push(tn);
+ tn.parent = current;
+ } else if (ni.level === current.info.level) {
+ const parent = current.parent ?? root;
+ parent.children.push(tn);
+ tn.parent = parent;
+ } else {
+ let ancestor = current.parent;
+ while (ancestor && ancestor.info.level >= ni.level) {
+ ancestor = ancestor.parent;
+ }
+ const target = ancestor ?? root;
+ target.children.push(tn);
+ tn.parent = target;
+ }
+ current = tn;
+ }
+ return root;
+}
+
+function computeSubtreeWidth(node) {
+ if (!node.children.length) {
+ node.subtreeWidth = NODE_W;
+ return NODE_W;
+ }
+ if (BRANCHING_EIPS.has(node.info.type)) {
+ let total = 0;
+ node.children.forEach((c, i) => {
+ if (i > 0) total += H_GAP;
+ total += computeSubtreeWidth(c);
+ });
+ node.subtreeWidth = Math.max(NODE_W, total);
+ } else {
+ node.subtreeWidth = node.children.reduce(
+ (max, c) => Math.max(max, computeSubtreeWidth(c)),
+ NODE_W,
+ );
+ }
+ return node.subtreeWidth;
+}
+
+function visualParentId(node) {
+ if (!node.parent) return null;
+ const parent = node.parent;
+ if (BRANCHING_EIPS.has(parent.info.type)) {
+ return parent.info.id;
+ }
+ const idx = parent.children.indexOf(node);
+ if (idx === 0) {
+ return parent.info.id;
+ }
+ return lastChainId(parent.children[idx - 1]);
+}
+
+function lastChainId(node) {
+ if (BRANCHING_EIPS.has(node.info.type) || !node.children.length) {
+ return node.info.id;
+ }
+ return lastChainId(node.children[node.children.length - 1]);
+}
+
+function assignPositions(node, x, y, parentWidth, positions) {
+ if (!node.info.id) {
+ console.warn('camel-route-diagram: node without an id is omitted from
the diagram', node.info);
+ return y + NODE_H;
+ }
+
+ const available = Math.max(node.subtreeWidth, parentWidth);
+ const nodeX = x + (available - NODE_W) / 2;
+
+ positions[node.info.id] = {
+ x: nodeX,
+ y,
+ w: NODE_W,
+ h: NODE_H,
+ parentId: visualParentId(node),
+ type: node.info.type,
+ code: node.info.code,
+ description: node.info.description ?? null,
+ uri: node.info.uri ?? null,
+ statistics: node.info.statistics ?? null,
+ };
+
+ if (!node.children.length) return y + NODE_H;
+
+ const childY = y + NODE_H + V_GAP;
+
+ if (BRANCHING_EIPS.has(node.info.type)) {
+ let childX = x + (available - node.subtreeWidth) / 2;
+ let maxBottom = childY;
+ for (const child of node.children) {
+ const bottom = assignPositions(child, childX, childY,
child.subtreeWidth, positions);
+ if (bottom > maxBottom) maxBottom = bottom;
+ childX += child.subtreeWidth + H_GAP;
+ }
+ return maxBottom;
+ } else {
+ let curY = childY;
+ for (const child of node.children) {
+ curY = assignPositions(child, x, curY, available, positions) +
V_GAP;
+ }
+ return curY - V_GAP;
+ }
+}
+
+function layoutRoute(route) {
+ const nodes = route.code ?? [];
+ if (!nodes.length) {
+ return { positions: {}, width: NODE_W + PADDING * 2, height: NODE_H +
PADDING * 2 };
+ }
+
+ const tree = buildTree(nodes);
+ computeSubtreeWidth(tree);
+
+ const positions = {};
+ assignPositions(tree, PADDING, PADDING, tree.subtreeWidth, positions);
+
+ let maxX = 0;
+ let maxYVal = 0;
+ for (const p of Object.values(positions)) {
+ maxX = Math.max(maxX, p.x + p.w);
+ maxYVal = Math.max(maxYVal, p.y + p.h);
+ }
+
+ return { positions, width: maxX + PADDING, height: maxYVal + PADDING };
+}
+
+// ─── Web component
────────────────────────────────────────────────────────────
+
+const TYPE_COLORS = {
+ route: 'var(--crd-color-route, #6366f1)',
+ from: 'var(--crd-color-from, #0ea5e9)',
+ to: 'var(--crd-color-to, #0ea5e9)',
+ log: 'var(--crd-color-log, #64748b)',
+ choice: 'var(--crd-color-choice, #f59e0b)',
+ when: 'var(--crd-color-when, #fbbf24)',
+ otherwise: 'var(--crd-color-otherwise, #fbbf24)',
+ doTry: 'var(--crd-color-doTry, #f59e0b)',
+ doCatch: 'var(--crd-color-doCatch, #fbbf24)',
+ doFinally: 'var(--crd-color-doFinally, #fbbf24)',
+ multicast: 'var(--crd-color-multicast, #8b5cf6)',
+ circuitBreaker: 'var(--crd-color-circuitBreaker, #ef4444)',
+};
+
+// SVG icon paths from Lucide (https://lucide.dev) — ISC License
+// Copyright (c) Lucide Contributors 2022; portions © Cole Bemis 2013-2022
(Feather, MIT)
+const ICONS = {
+ workflow: '<rect width="8" height="8" x="3" y="3" rx="2"/><path
d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
+ 'log-in': '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2
2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12"
y2="12"/>',
+ 'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1
2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12"
y2="12"/>',
+ 'file-text': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2
2 0 0 0 2-2V7Z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16
17H8"/><path d="M10 9H8"/>',
+ 'git-branch': '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18"
cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
+ 'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4
4v7a4 4 0 0 0 4 4h12"/>',
+ split: '<path d="M16 3h5v5"/><path d="M8 3H3v5"/><path
d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"/><path d="m15 9 6-6"/>',
+ shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0
1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0
0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
+ 'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0
0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
+ flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4
1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>',
+ zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0
1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0
1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
+ box: '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2
0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21
16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
+};
+
+const TYPE_ICON = {
+ route: 'workflow', from: 'log-in', to: 'log-out', log: 'file-text',
+ choice: 'git-branch', when: 'corner-down-right', otherwise:
'corner-down-right',
+ doTry: 'shield', doCatch: 'alert-triangle', doFinally: 'flag',
+ multicast: 'split', circuitBreaker: 'zap',
+};
+
+function iconFor(type) {
+ return ICONS[TYPE_ICON[type]] ?? ICONS.box;
+}
+
+function nodeColor(type) {
+ return TYPE_COLORS[type] ?? 'var(--crd-color-default, #6366f1)';
+}
+
+function truncate(text, maxLen = 28) {
+ if (!text) return '';
+ const clean = text.replace(/^\.+/, '');
+ return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
+}
+
+function formatStat(stats) {
+ if (!stats) return null;
+ const total = stats.exchangesTotal ?? 0;
+ const failed = stats.exchangesFailed ?? 0;
+ return `✓${total} ✗${failed}`;
+}
+
+function esc(s) {
+ return String(s ?? '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"');
+}
+
+// Node ids are interpolated into a DOM id and a url(#...) reference, so
restrict them to characters that are
+// safe in both contexts (esc() alone would not produce a valid id fragment).
+function safeId(id) {
+ return String(id).replace(/[^A-Za-z0-9_-]/g, '_');
+}
+
+const COMPONENT_STYLE = `
+ :host {
+ display: block;
+ /*
+ * fit-content makes the host expand to the SVG's intrinsic width so the
+ * parent scroll container sees real overflow and shows a scrollbar.
+ * min-width: 100% prevents collapsing when the diagram is narrower than
+ * the container.
+ */
+ width: fit-content;
+ min-width: 100%;
+ font-family: var(--crd-font, system-ui, sans-serif);
+ font-size: var(--crd-font-size, 12px);
+ color: var(--crd-fg, #1e293b);
+ }
+ @media (prefers-color-scheme: dark) {
+ :host { color: var(--crd-fg, #e2e8f0); }
+ }
+ /* Background on .wrap (not :host) so it tracks the SVG width on scroll. */
+ .wrap {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 24px;
+ background: var(--crd-bg, transparent);
+ --crd-node-bg: var(--crd-bg, #ffffff);
+ }
+ @media (prefers-color-scheme: dark) {
+ .wrap {
+ background: var(--crd-bg, #0f172a);
+ --crd-node-bg: var(--crd-bg, #0f172a);
+ }
+ }
+ .route-col { flex-shrink: 0; }
+ .error { color: #ef4444; padding: 8px; }
+ .loading { opacity: .6; padding: 8px; }
+ .route-label {
+ font-weight: 600;
+ font-size: 0.9em;
+ padding: 4px 0 2px 0;
+ opacity: .8;
+ }
+ svg { display: block; overflow: visible; }
+`;
+
+/**
+ * A web component that renders Apache Camel route diagrams as interactive SVG.
+ *
+ * Attributes:
+ * src - URL of the route-structure dev console endpoint (required)
+ * refresh - polling interval in ms; 0 = disabled (default: 0)
+ * filter - route ID filter, forwarded as ?filter= query param (default:
all routes)
+ *
+ * CSS custom properties (all optional):
+ * --crd-bg, --crd-node-bg, --crd-fg, --crd-edge, --crd-font,
--crd-font-size, --crd-stat
+ *
--crd-color-{route,from,to,log,choice,when,otherwise,doTry,doCatch,doFinally,...,default}
+ *
+ * @since 4.21
+ */
+class CamelRouteDiagram extends HTMLElement {
+ static observedAttributes = ['src', 'refresh', 'filter'];
+
+ #src = '';
+ #refresh = 0;
+ #filter = '';
+ #timer = null;
+ #uid = Math.random().toString(36).slice(2);
+ #controller = null;
+ #data = null;
+ #error = null;
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: 'open' });
+ }
+
+ //noinspection JSUnusedGlobalSymbols
+ connectedCallback() {
+ this.#scheduleRefresh();
+ this.#render();
+ this.#doFetch();
+ }
+
+ //noinspection JSUnusedGlobalSymbols
+ disconnectedCallback() {
+ clearInterval(this.#timer);
+ this.#timer = null;
+ this.#controller?.abort();
+ }
+
+ //noinspection JSUnusedGlobalSymbols
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (oldValue === newValue) return;
+ switch (name) {
+ case 'src':
+ this.#src = newValue ?? '';
+ if (this.isConnected) this.#doFetch();
+ break;
+ case 'filter':
+ this.#filter = newValue ?? '';
+ if (this.isConnected) this.#doFetch();
+ break;
+ case 'refresh':
+ this.#refresh = Number(newValue) || 0;
+ if (this.isConnected) this.#scheduleRefresh();
+ break;
+ }
+ }
+
+ #scheduleRefresh() {
+ clearInterval(this.#timer);
+ this.#timer = null;
+ if (this.#refresh > 0) {
+ this.#timer = setInterval(() => this.#doFetch(), this.#refresh);
+ }
+ }
+
+ async #doFetch() {
+ const src = this.#src?.trim();
+ if (!src) return;
+ // Cancel any in-flight request so the last-sent response always wins.
+ this.#controller?.abort();
+ this.#controller = new AbortController();
+ try {
+ const url = new URL(src, location.href);
+ if (this.#filter) url.searchParams.set('filter', this.#filter);
+ url.searchParams.set('metric', 'true');
+ const res = await fetch(url, { signal: this.#controller.signal });
+ if (!res.ok) {
+ this.#error = `HTTP ${res.status} ${res.statusText}`;
+ this.#render();
+ return;
+ }
+ const data = await res.json();
+ if (!Array.isArray(data?.routes)) {
+ this.#error = 'Unexpected response: missing routes array';
+ this.#render();
+ return;
+ }
+ this.#data = data;
+ this.#error = null;
+ this.#render();
+ } catch (e) {
+ if (e.name !== 'AbortError') {
+ this.#error = e.message;
+ this.#render();
+ }
+ }
+ }
+
+ #render() {
+ this.shadowRoot.innerHTML = this.#buildHTML();
+ }
+
+ #buildHTML() {
+ const style = `<style>${COMPONENT_STYLE}</style>`;
+ if (this.#error) return `${style}<div class="wrap"><p class="error">⚠
${esc(this.#error)}</p></div>`;
+ if (!this.#data) return `${style}<div class="wrap"><p
class="loading">Loading diagram…</p></div>`;
+ return style + `<div class="wrap">${this.#data.routes.map((r, i) =>
this.#routeHTML(r, i)).join('')}</div>`;
+ }
+
+ #routeHTML(route, routeIdx) {
+ const { positions, width, height } = layoutRoute(route);
+ const ids = Object.keys(positions);
+ const pfx = `t${this.#uid}r${routeIdx}`;
+ const defs = ids.map(id => {
+ const p = positions[id];
+ return `<clipPath id="${pfx}${safeId(id)}">` +
+ `<rect x="${p.x + 28}" y="${p.y}" width="${NODE_W - 30}"
height="${NODE_H}"/></clipPath>`;
+ }).join('');
+ return `<div class="route-col">
+ <div class="route-label">${esc(route.routeId)}</div>
+ <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"
+ aria-label="Route diagram for ${esc(route.routeId)}">
+ <defs>${defs}</defs>
+ ${ids.map(id => this.#edgeHTML(id, positions)).join('')}
+ ${ids.map(id => this.#nodeHTML(positions[id],
`${pfx}${safeId(id)}`)).join('')}
+ </svg>
+ </div>`;
+ }
+
+ #edgeHTML(id, positions) {
+ const pos = positions[id];
+ if (!pos.parentId) return '';
+ const parent = positions[pos.parentId];
+ if (!parent) return '';
+
+ const x1 = parent.x + NODE_W / 2;
+ const y1 = parent.y + NODE_H;
+ const x2 = pos.x + NODE_W / 2;
+ const y2 = pos.y;
+ const endY = y2 - ARROW_SIZE / 2;
+ const edge = x1 === x2
+ ? `M${x1},${y1} L${x2},${endY}`
+ : `M${x1},${y1} L${x1},${(y1 + y2) / 2} L${x2},${(y1 + y2) / 2}
L${x2},${endY}`;
+
+ return `
+ <path
+ d="${edge}"
+ fill="none"
+ stroke="var(--crd-edge, #94a3b8)"
+ stroke-width="1.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"/>
+ <polygon
+ points="${x2 - ARROW_SIZE},${y2 - ARROW_SIZE} ${x2},${y2} ${x2 +
ARROW_SIZE},${y2 - ARROW_SIZE}"
+ fill="var(--crd-edge, #94a3b8)"/>`;
+ }
+
+ #nodeHTML(pos, clipId) {
+ const label = truncate(pos.description ?? pos.code);
+ const stat = formatStat(pos.statistics);
+ const fill = nodeColor(pos.type);
+ const textX = pos.x + 30;
+ const textY = pos.y + NODE_H / 2 + 4;
+
+ return `
+ <g role="img" aria-label="${esc(pos.type)}: ${esc(label)}">
+ <rect x="${pos.x}" y="${pos.y}" width="${NODE_W}" height="${NODE_H}"
+ rx="6" ry="6" fill="var(--crd-node-bg, #ffffff)"/>
+ <rect x="${pos.x}" y="${pos.y}" width="${NODE_W}" height="${NODE_H}"
+ rx="6" ry="6"
+ fill="${fill}" fill-opacity="0.15"
+ stroke="${fill}" stroke-width="1.5"/>
+ <text x="${textX}" y="${stat ? textY - 4 : textY}"
+ text-anchor="start" fill="currentColor" font-size="11"
+ clip-path="url(#${clipId})">
+ ${esc(label)}
+ </text>
+ ${stat ? `
+ <text x="${textX}" y="${pos.y + NODE_H - 3}"
+ text-anchor="start" fill="var(--crd-stat, #64748b)" font-size="9"
+ clip-path="url(#${clipId})">
+ ${esc(stat)}
+ </text>` : ''}
+ <g transform="translate(${pos.x + 12},${pos.y + (NODE_H - 14) / 2})
scale(0.5833)"
+ fill="none" stroke="${fill}" stroke-width="2.4"
+ stroke-linecap="round" stroke-linejoin="round"
pointer-events="none">
+ ${iconFor(pos.type)}
+ </g>
+ </g>`;
+ }
+}
+
+customElements.define('camel-route-diagram', CamelRouteDiagram);
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java
new file mode 100644
index 000000000000..f1415f6bf9e8
--- /dev/null
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RouteDiagramHelperTest {
+
+ @Test
+ void wrapTextShortReturnedAsIs() {
+ assertThat(RouteDiagramHelper.wrapText("short",
20)).containsExactly("short");
+ }
+
+ @Test
+ void wrapTextLongWrapsAtBreakCharacters() {
+ List<String> lines =
RouteDiagramHelper.wrapText("kafka:my-topic?brokers=localhost:9092", 15);
+ assertThat(lines).hasSizeGreaterThan(1);
+ assertThat(String.join("",
lines)).contains("kafka").contains("localhost");
+ }
+
+ @Test
+ void wrapTextRemainingThatFitsOnLastLineIsAppendedWithoutEllipsis() {
+ // "aaa bbb ccc dd" with maxWidth=5:
+ // round 1 → "aaa", round 2 → "bbb", round 3 → "ccc", remaining =
"dd"
+ // lastLine("ccc").len=3 + remaining("dd").len=2 = 5 <= maxWidth →
append, no "..."
+ List<String> lines = RouteDiagramHelper.wrapText("aaa bbb ccc dd", 5);
+ assertThat(lines).containsExactly("aaa", "bbb", "cccdd");
+ }
+
+ @Test
+ void wrapTextRemainingThatDoesNotFitOnLastLineIsTruncatedWithEllipsis() {
+ // Same structure but remaining = "ddddd" (len 5): 3+5=8 > 5 →
truncate with "..."
+ List<String> lines = RouteDiagramHelper.wrapText("aaa bbb ccc ddddd",
5);
+ assertThat(lines).hasSize(3);
+ assertThat(lines.get(2)).endsWith("...");
+ }
+}
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
new file mode 100644
index 000000000000..c8a569a079f4
--- /dev/null
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies computeSubtreeWidth and assignPositions behaviour through the
public layoutRoute() API. The same scenarios
+ * are mirrored in the browser tests in integration-test.html.
+ *
+ * Java constants (default constructor, SCALE=2): nodeWidth = 360
(DEFAULT_BOX_WIDTH * SCALE) hGap = 180 (nodeWidth / 2)
+ * V_GAP = 80 (40 * SCALE) PADDING = 60 (30 * SCALE)
+ */
+class RouteDiagramLayoutEngineTest {
+
+ private static final RouteDiagramLayoutEngine ENGINE = new
RouteDiagramLayoutEngine();
+ private static final int NODE_W = ENGINE.getNodeWidth(); // 360
+ private static final int H_GAP = NODE_W / 2; // 180
+ private static final int PADDING = RouteDiagramLayoutEngine.PADDING; // 60
+
+ // ─── helpers
─────────────────────────────────────────────────────────────
+
+ private static RouteDiagramLayoutEngine.NodeInfo node(String type, String
id, int level) {
+ RouteDiagramLayoutEngine.NodeInfo n = new
RouteDiagramLayoutEngine.NodeInfo();
+ n.type = type;
+ n.id = id;
+ n.level = level;
+ n.code = type;
+ return n;
+ }
+
+ private static RouteDiagramLayoutEngine.RouteInfo
route(RouteDiagramLayoutEngine.NodeInfo... nodes) {
+ RouteDiagramLayoutEngine.RouteInfo r = new
RouteDiagramLayoutEngine.RouteInfo();
+ r.routeId = "test";
+ r.nodes.addAll(List.of(nodes));
+ return r;
+ }
+
+ private static RouteDiagramLayoutEngine.LayoutNode findNode(
+ RouteDiagramLayoutEngine.LayoutRoute lr, String id) {
+ return lr.nodes.stream()
+ .filter(n -> id.equals(n.id))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No layout node with id:
" + id));
+ }
+
+ // ─── computeSubtreeWidth (verified through node positions)
───────────────
+
+ @Test
+ void leafNodeSubtreeWidthEqualsNodeWidth() {
+ // A single leaf node fills exactly one node-width slot.
+ // nodeX = PADDING + (subtreeWidth - NODE_W) / 2; if subtreeWidth ==
NODE_W, nodeX == PADDING.
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("log", "l1", 0)), 0);
+
+ RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1");
+ assertThat(l1.x).as("leaf node must be placed at PADDING (subtreeWidth
== nodeWidth)").isEqualTo(PADDING);
+ }
+
+ @Test
+ void branchingEipSubtreeWidthIsSumOfBranchWidthsPlusGaps() {
+ // choice -> [when, otherwise], both leaves.
+ // subtreeWidth(choice) = NODE_W + H_GAP + NODE_W = NODE_W*2 + H_GAP
+ // when.x = PADDING (leftmost child)
+ // ow.x = PADDING + NODE_W + H_GAP
+ // gap between children = NODE_W + H_GAP
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("choice", "ch", 0),
+ node("when", "w1", 1),
+ node("otherwise", "ow", 1)),
+ 0);
+
+ RouteDiagramLayoutEngine.LayoutNode w1 = findNode(lr, "w1");
+ RouteDiagramLayoutEngine.LayoutNode ow = findNode(lr, "ow");
+ assertThat(ow.x - w1.x)
+ .as("gap between two leaf branches of a branching EIP must
equal NODE_W + H_GAP")
+ .isEqualTo(NODE_W + H_GAP);
+ }
+
+ @Test
+ void nonBranchingNodeSubtreeWidthIsMaxOfChildWidths() {
+ // route -> [from, to] (linear siblings, both leaves, equal widths).
+ // subtreeWidth(route) = max(NODE_W, NODE_W) = NODE_W
+ // Both children should be placed at x == PADDING.
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("route", "r1", 0),
+ node("from", "f1", 1),
+ node("to", "t1", 1)),
+ 0);
+
+ RouteDiagramLayoutEngine.LayoutNode f1 = findNode(lr, "f1");
+ RouteDiagramLayoutEngine.LayoutNode t1 = findNode(lr, "t1");
+ assertThat(f1.x).as("first linear child x must equal
PADDING").isEqualTo(PADDING);
+ assertThat(t1.x).as("second linear child x must equal
PADDING").isEqualTo(PADDING);
+ }
+
+ // ─── assignPositions
─────────────────────────────────────────────────────
+
+ @Test
+ void linearChainEachNodeConnectsToItsVisualPredecessor() {
+ // route -> from -> log -> to (flat level-1 siblings processed
linearly).
+ // f1.parentNode == r1, l1.parentNode == f1, t1.parentNode == l1.
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("route", "r1", 0),
+ node("from", "f1", 1),
+ node("log", "l1", 1),
+ node("to", "t1", 1)),
+ 0);
+
+ RouteDiagramLayoutEngine.LayoutNode r1 = findNode(lr, "r1");
+ RouteDiagramLayoutEngine.LayoutNode f1 = findNode(lr, "f1");
+ RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1");
+ RouteDiagramLayoutEngine.LayoutNode t1 = findNode(lr, "t1");
+
+ assertThat(r1.parentNode).as("root must have no parent").isNull();
+ assertThat(f1.parentNode).as("f1 must connect from r1").isSameAs(r1);
+ assertThat(l1.parentNode).as("l1 must connect from f1, not
r1").isSameAs(f1);
+ assertThat(t1.parentNode).as("t1 must connect from l1, not
r1").isSameAs(l1);
+ }
+
+ @Test
+ void singleChainRouteAssignsStrictlyIncreasingYValues() {
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("route", "r1", 0),
+ node("from", "f1", 1),
+ node("log", "l1", 1),
+ node("to", "t1", 1)),
+ 0);
+
+ int yr1 = findNode(lr, "r1").y;
+ int yf1 = findNode(lr, "f1").y;
+ int yl1 = findNode(lr, "l1").y;
+ int yt1 = findNode(lr, "t1").y;
+
+ assertThat(yf1).as("f1.y must be below r1").isGreaterThan(yr1);
+ assertThat(yl1).as("l1.y must be below f1").isGreaterThan(yf1);
+ assertThat(yt1).as("t1.y must be below l1").isGreaterThan(yl1);
+ }
+
+ @Test
+ void branchingEipChildrenAreLaidOutSideBySide() {
+ // choice -> [when, otherwise]: children must share the same y,
different x.
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("choice", "ch", 0),
+ node("when", "w1", 1),
+ node("otherwise", "ow", 1)),
+ 0);
+
+ RouteDiagramLayoutEngine.LayoutNode w1 = findNode(lr, "w1");
+ RouteDiagramLayoutEngine.LayoutNode ow = findNode(lr, "ow");
+
+ assertThat(w1.x).as("when must be to the left of
otherwise").isLessThan(ow.x);
+ assertThat(w1.y).as("both branches must start at the same
y").isEqualTo(ow.y);
+ }
+
+ @Test
+ void nextSiblingIsPlacedBelowDeepestDescendantOfPreviousSibling() {
+ // route -> choice -> [when -> log_a, otherwise -> log_b], log_after
+ // log_after must be below BOTH log_a and log_b.
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("route", "r1", 0),
+ node("choice", "ch", 1),
+ node("when", "wh", 2),
+ node("log", "la", 3),
+ node("otherwise", "ow", 2),
+ node("log", "lb", 3),
+ node("log", "lafter", 1)),
+ 0);
+
+ RouteDiagramLayoutEngine.LayoutNode la = findNode(lr, "la");
+ RouteDiagramLayoutEngine.LayoutNode lb = findNode(lr, "lb");
+ RouteDiagramLayoutEngine.LayoutNode lafter = findNode(lr, "lafter");
+
+ assertThat(lafter.y)
+ .as("lafter must be below la")
+ .isGreaterThan(la.y + la.height);
+ assertThat(lafter.y)
+ .as("lafter must be below lb")
+ .isGreaterThan(lb.y + lb.height);
+ }
+
+ @Test
+ void linearChainAfterBranchingEipConnectsFromBranchingEip() {
+ // route -> choice -> [when, otherwise], log_after
+ // log_after.parentNode must be the choice node, not when or otherwise.
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("route", "r1", 0),
+ node("choice", "ch", 1),
+ node("when", "wh", 2),
+ node("otherwise", "ow", 2),
+ node("log", "lafter", 1)),
+ 0);
+
+ RouteDiagramLayoutEngine.LayoutNode ch = findNode(lr, "ch");
+ RouteDiagramLayoutEngine.LayoutNode lafter = findNode(lr, "lafter");
+
+ assertThat(lafter.parentNode)
+ .as("node after a branching EIP must connect from the
branching EIP itself")
+ .isSameAs(ch);
+ }
+
+ @Test
+ void layoutRouteMaxYEqualsDeepestNodeBottom() {
+ // route -> from -> log: maxY must equal log.y + log.height.
+ RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(
+ route(node("route", "r1", 0),
+ node("from", "f1", 1),
+ node("log", "l1", 1)),
+ 0);
+
+ RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1");
+ assertThat(lr.maxY)
+ .as("maxY must equal the bottom of the deepest node")
+ .isEqualTo(l1.y + l1.height);
+ }
+}
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
index 4024de903f33..078d67e9b669 100644
---
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
@@ -1041,14 +1041,14 @@ class RouteDiagramTest {
@Test
void testAsciiWrapTextShort() {
- List<String> lines = RouteDiagramAsciiRenderer.wrapText("timer:tick",
20);
+ List<String> lines = RouteDiagramHelper.wrapText("timer:tick", 20);
assertEquals(1, lines.size());
assertEquals("timer:tick", lines.get(0));
}
@Test
void testAsciiWrapTextWrap() {
- List<String> lines =
RouteDiagramAsciiRenderer.wrapText("kafka:my-topic?brokers=localhost:9092", 20);
+ List<String> lines =
RouteDiagramHelper.wrapText("kafka:my-topic?brokers=localhost:9092", 20);
assertTrue(lines.size() > 1, "Long text should wrap");
String rejoined = String.join("", lines);
assertTrue(rejoined.contains("kafka:"));
@@ -1058,7 +1058,7 @@ class RouteDiagramTest {
@Test
void testAsciiWrapTextTruncate() {
String veryLong = "a]".repeat(60);
- List<String> lines = RouteDiagramAsciiRenderer.wrapText(veryLong, 20);
+ List<String> lines = RouteDiagramHelper.wrapText(veryLong, 20);
assertTrue(lines.size() <= 3, "Should not exceed 3 lines");
assertTrue(lines.get(lines.size() - 1).endsWith("..."), "Truncated
text should end with ...");
}
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
index d94497ef87ea..c9643c1d0cb3 100644
---
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
@@ -274,16 +274,6 @@ class TopologyDiagramTest {
assertEquals("internal", edges.get(0).connectionType);
}
- @Test
- void testWrapText() {
- List<String> lines = TopologyAsciiRenderer.wrapText("short", 20);
- assertEquals(1, lines.size());
- assertEquals("short", lines.get(0));
-
- List<String> wrapped = TopologyAsciiRenderer.wrapText("this is a
longer text that needs wrapping", 15);
- assertTrue(wrapped.size() > 1);
- }
-
@Test
void testOrderProcessingTopology() {
List<TopologyNodeInfo> nodes = List.of(
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
new file mode 100644
index 000000000000..e2c244f5c369
--- /dev/null
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that the web component and its third-party notices are bundled on
the classpath, and that the bundle still
+ * contains the load-bearing markers a custom element needs. These are
packaging-integrity checks based on text content,
+ * not runtime behaviour tests: the actual rendering, layout and fetch
lifecycle of the component are exercised in the
+ * browser by {@code src/test/resources/integration-test.html}, which is not
run as part of the CI build.
+ */
+class WebComponentBundleTest {
+
+ @Test
+ void bundledJsExistsInClasspath() {
+ URL url = getClass().getClassLoader()
+
.getResource("META-INF/resources/camel/diagram/camel-route-diagram.js");
+ assertThat(url).as("camel-route-diagram.js must be
bundled").isNotNull();
+ }
+
+ @Test
+ void bundledJsIsNonEmpty() throws IOException {
+ try (InputStream is = getClass().getClassLoader()
+
.getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js"))
{
+ assertThat(is).isNotNull();
+ assertThat(is.readAllBytes().length).isGreaterThan(1000);
+ }
+ }
+
+ @Test
+ void bundledJsContainsCustomElementRegistration() throws IOException {
+ try (InputStream is = getClass().getClassLoader()
+
.getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js"))
{
+ assertThat(is).isNotNull();
+ String content = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
+ assertThat(content)
+ .as("bundle must register the camel-route-diagram custom
element")
+ .contains("customElements.define")
+ .contains("camel-route-diagram");
+ }
+ }
+
+ @Test
+ void bundledJsUsesArrowMarkerGeometryThatAnchorsAtTheTip() throws
IOException {
+ try (InputStream is = getClass().getClassLoader()
+
.getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js"))
{
+ assertThat(is).isNotNull();
+ String content = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
+ assertThat(content)
+ .as("branch connectors must render an explicit arrowhead
at the path endpoint")
+ .contains("const ARROW_SIZE = 6")
+ .contains("<polygon")
+ .contains("stroke-linecap=\"round\"");
+ }
+ }
+
+ @Test
+ void thirdPartyNoticesMentionsLucide() throws IOException {
+ try (InputStream is = getClass().getClassLoader()
+
.getResourceAsStream("META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt"))
{
+ assertThat(is).isNotNull();
+ String content = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
+ assertThat(content)
+ .as("THIRD-PARTY-NOTICES.txt must attribute Lucide with
ISC license")
+ .contains("Lucide")
+ .contains("ISC");
+ }
+ }
+}
diff --git a/components/camel-diagram/src/test/resources/integration-test.html
b/components/camel-diagram/src/test/resources/integration-test.html
new file mode 100644
index 000000000000..9e7c80473640
--- /dev/null
+++ b/components/camel-diagram/src/test/resources/integration-test.html
@@ -0,0 +1,862 @@
+<!--
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE html>
+<!--
+ Integration test page for the camel-route-diagram web component.
+
+ All tests are automated — open the page in a browser and every row should
+ be green. No running Camel instance is needed; all data is mocked inline.
+
+ Browsers block ES module imports from file:// URLs (CORS/null-origin).
+ Serve from the src/ directory:
+
+ cd components/camel-diagram/src/
+ python3 -m http.server 8080
+ open http://localhost:8080/test/resources/integration-test.html
+-->
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>camel-route-diagram · Integration Test</title>
+ <style>
+ /* ─── Camel design tokens (mirrored from smoke-test.html) ─── */
+ :root {
+ --camel-orange: #e97826;
+ --camel-orange-dark: #cf7428;
+ --camel-navy: #303284;
+ --camel-purple: #4f51ae;
+ --camel-light: #f5f5f5;
+ --camel-border: #e1e1e1;
+ --camel-text: #333;
+ --camel-muted: #5d5d5d;
+ --camel-white: #fff;
+ --radius: 6px;
+ }
+
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+ html { scroll-behavior: smooth; }
+
+ body {
+ font-family: system-ui, 'Open Sans', sans-serif;
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--camel-text);
+ background: var(--camel-light);
+ min-height: 100vh;
+ }
+
+ /* ─── Top nav bar ─── */
+ .site-nav {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: var(--camel-navy);
+ color: var(--camel-white);
+ height: 52px;
+ display: flex;
+ align-items: center;
+ padding: 0 1.5rem;
+ gap: 1rem;
+ box-shadow: 0 2px 10px rgba(0,0,0,.35);
+ }
+ .site-nav .logo-mark {
+ width: 30px;
+ height: 30px;
+ background: var(--camel-orange);
+ border-radius: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 800;
+ font-size: 16px;
+ flex-shrink: 0;
+ letter-spacing: -1px;
+ }
+ .site-nav .brand-name { font-weight: 700; font-size: .95rem; }
+ .site-nav .brand-sub { font-size: .7rem; opacity: .65; }
+ .site-nav .nav-spacer { flex: 1; }
+ .site-nav .nav-links { display: flex; gap: 1.25rem; }
+ .site-nav .nav-links a {
+ color: rgba(255,255,255,.75);
+ text-decoration: none;
+ font-size: .8rem;
+ font-weight: 600;
+ transition: color .15s;
+ }
+ .site-nav .nav-links a:hover { color: var(--camel-orange); }
+ .badge-pill {
+ background: var(--camel-orange);
+ color: var(--camel-white);
+ font-size: .65rem;
+ font-weight: 700;
+ padding: 2px 9px;
+ border-radius: 99px;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+ }
+
+ /* ─── Hero ─── */
+ .hero {
+ background: var(--camel-navy);
+ color: var(--camel-white);
+ padding: 2.5rem 1.5rem 2.75rem;
+ border-bottom: 4px solid var(--camel-orange);
+ }
+ .hero-inner { max-width: 900px; margin: 0 auto; }
+ .hero h1 {
+ font-size: 1.75rem;
+ font-weight: 800;
+ margin-bottom: .4rem;
+ letter-spacing: -.5px;
+ }
+ .hero h1 code {
+ color: var(--camel-orange);
+ font-family: 'Courier New', monospace;
+ font-size: 1.55rem;
+ }
+ .hero p {
+ opacity: .8;
+ max-width: 640px;
+ font-size: .9rem;
+ margin-bottom: .75rem;
+ }
+ .hero-hint {
+ display: inline-block;
+ background: rgba(255,255,255,.07);
+ border: 1px solid rgba(255,255,255,.15);
+ border-radius: var(--radius);
+ padding: .5rem .9rem;
+ font-family: 'Courier New', monospace;
+ font-size: .78rem;
+ color: #9db8d2;
+ line-height: 1.7;
+ }
+
+ /* ─── Layout ─── */
+ .content {
+ max-width: 1140px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 3rem;
+ }
+
+ /* ─── Summary badge ─── */
+ #summary {
+ display: inline-block;
+ font-size: 1rem;
+ font-weight: 700;
+ padding: .4rem 1.1rem;
+ border-radius: 99px;
+ margin-bottom: 1.75rem;
+ letter-spacing: .02em;
+ }
+ #summary.running { background: #e8eaf6; color: var(--camel-navy); }
+ #summary.all-pass { background: #e8f5e9; color: #1b5e20; }
+ #summary.has-fail { background: #fce4ec; color: #880e4f; }
+
+ /* ─── Sections ─── */
+ .section { margin-bottom: 2.5rem; }
+
+ .section-header {
+ display: flex;
+ align-items: center;
+ gap: .65rem;
+ margin-bottom: 1rem;
+ padding-bottom: .6rem;
+ border-bottom: 2px solid var(--camel-border);
+ }
+ .section-header h2 {
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: var(--camel-navy);
+ }
+ .section-tag {
+ font-size: .65rem;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 99px;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ }
+ .tag-lifecycle { background: #e8eaf6; color: #303284; }
+ .tag-render { background: #fff3e0; color: #9a4200; }
+ .tag-api { background: #e3f2fd; color: #0d47a1; }
+ .tag-eip { background: #fce4ec; color: #880e4f; }
+ .tag-color { background: #f3e5f5; color: #4a148c; }
+ .tag-polling { background: #e8f5e9; color: #1b5e20; }
+
+ /* ─── Cards ─── */
+ .card {
+ background: var(--camel-white);
+ border: 1px solid var(--camel-border);
+ border-top: 3px solid var(--camel-orange);
+ border-radius: var(--radius);
+ overflow: hidden;
+ }
+ .card-header {
+ padding: .7rem 1rem .55rem;
+ border-bottom: 1px solid var(--camel-border);
+ }
+ .card-header h3 {
+ font-size: .9rem;
+ font-weight: 700;
+ color: var(--camel-navy);
+ margin-bottom: .15rem;
+ }
+ .card-header p {
+ font-size: .78rem;
+ color: var(--camel-muted);
+ }
+ .card-header p code {
+ font-size: .78rem;
+ background: #f0f0f0;
+ border-radius: 3px;
+ padding: 0 3px;
+ }
+
+ /* ─── Test rows ─── */
+ .test-list { list-style: none; }
+ .test-row {
+ display: flex;
+ align-items: baseline;
+ gap: .6rem;
+ padding: .55rem 1rem;
+ font-size: .84rem;
+ border-bottom: 1px solid var(--camel-border);
+ }
+ .test-row:last-child { border-bottom: none; }
+ .test-row.pending { color: var(--camel-muted); }
+ .test-row.pass { background: #f0faf3; }
+ .test-row.fail { background: #fff5f5; }
+
+ .badge {
+ font-size: .62rem;
+ font-weight: 800;
+ padding: 2px 7px;
+ border-radius: 4px;
+ letter-spacing: .07em;
+ text-transform: uppercase;
+ flex-shrink: 0;
+ }
+ .pass-badge { background: #c8e6c9; color: #1b5e20; }
+ .fail-badge { background: #ffcdd2; color: #b71c1c; }
+ .run-badge { background: #e8eaf6; color: #303284; }
+
+ .test-label { flex: 1; }
+ .err-msg { font-size: .78rem; color: #b71c1c; font-family: 'Courier
New', monospace; }
+
+ /* ─── Footer ─── */
+ .site-footer {
+ background: var(--camel-navy);
+ color: rgba(255,255,255,.55);
+ text-align: center;
+ padding: 1.5rem;
+ font-size: .78rem;
+ }
+ .site-footer a { color: var(--camel-orange); text-decoration: none; }
+ .site-footer a:hover { text-decoration: underline; }
+ </style>
+</head>
+<body>
+
+<!-- ─── Navigation ─── -->
+<nav class="site-nav" aria-label="Site navigation">
+ <div class="logo-mark" aria-hidden="true">C</div>
+ <div>
+ <div class="brand-name">Apache Camel</div>
+ <div class="brand-sub">camel-route-diagram</div>
+ </div>
+ <div class="nav-spacer"></div>
+ <nav class="nav-links" aria-label="Jump to section">
+ <a href="#lifecycle">Lifecycle</a>
+ <a href="#rendering">Rendering</a>
+ <a href="#api">Attribute API</a>
+ <a href="#eips">EIP Nodes</a>
+ <a href="#colors">Colors</a>
+ <a href="#polling">Polling</a>
+ <a href="smoke-test.html">Smoke Test</a>
+ </nav>
+ <span class="badge-pill">Integration Tests</span>
+</nav>
+
+<!-- ─── Hero ─── -->
+<header class="hero">
+ <div class="hero-inner">
+ <h1><code><camel-route-diagram></code> Integration Tests</h1>
+ <p>
+ Automated browser tests for the route-diagram web component. Every
assertion drives the public
+ component API (attributes, shadow DOM) with mocked fetch responses. Open
the page and all rows
+ should turn green — no running Camel instance required.
+ </p>
+ <span class="hero-hint">
+ cd components/camel-diagram/src/<br>
+ python3 -m http.server 8080 && open
http://localhost:8080/test/resources/integration-test.html
+ </span>
+ </div>
+</header>
+
+<!-- ─── Main content ─── -->
+<main class="content">
+ <div id="summary" class="running">Running…</div>
+ <div id="results"></div>
+</main>
+
+<!-- Hidden container for component instances created during tests -->
+<div id="mount" hidden aria-hidden="true"></div>
+
+<!-- ─── Footer ─── -->
+<footer class="site-footer">
+ <p>
+ <a href="https://camel.apache.org/" rel="noopener noreferrer">Apache
Camel</a>
+ ·
+ <code>camel-route-diagram</code> web component
+ ·
+ Integration tests — not a production page.
+ </p>
+</footer>
+
+<script type="module">
+import
'../../main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js';
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Mini test runner
+ * ══════════════════════════════════════════════════════════════════════════
*/
+
+const suites = [];
+let currentSuite = null;
+
+function describe(label, fn) {
+ const suite = { label, tests: [] };
+ suites.push(suite);
+ currentSuite = suite;
+ fn();
+ currentSuite = null;
+}
+
+function it(label, fn) {
+ currentSuite.tests.push({ label, fn });
+}
+
+function expect(value) {
+ const pass = (condition, msg) => { if (!condition) throw new Error(msg); };
+ const str = () => String(value ?? '');
+ const matchers = {
+ toBe: (x) => pass(value === x, `Expected
${JSON.stringify(x)}, got ${JSON.stringify(value)}`),
+ toBeTruthy: () => pass(!!value, `Expected truthy,
got ${JSON.stringify(value)}`),
+ toBeFalsy: () => pass(!value, `Expected falsy,
got ${JSON.stringify(value)}`),
+ toContain: (x) => pass(str().includes(x), `Expected to
contain ${JSON.stringify(x)}`),
+ toBeGreaterThan: (n) => pass(value > n, `Expected > ${n},
got ${value}`),
+ };
+ matchers.not = {
+ toContain: (x) => pass(!str().includes(x), `Expected NOT to contain
${JSON.stringify(x)}`),
+ };
+ return matchers;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Helpers
+ * ══════════════════════════════════════════════════════════════════════════
*/
+
+let fetchLog = [];
+const mountEl = document.getElementById('mount');
+
+function mockFetch(handler) {
+ window.fetch = (url, _opts) => {
+ fetchLog.push(String(url));
+ return handler(url);
+ };
+}
+
+function mount(attrs = {}) {
+ const el = document.createElement('camel-route-diagram');
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
+ mountEl.appendChild(el);
+ return el;
+}
+
+/**
+ * Polls `predicate` every 30 ms until it returns truthy or `timeout` ms
elapses.
+ * Rejects with a descriptive error on timeout.
+ */
+function waitFor(predicate, timeout = 2000) {
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const tick = setInterval(() => {
+ try {
+ if (predicate()) { clearInterval(tick); resolve(); return; }
+ } catch (_) { /* predicate may throw transiently */ }
+ if (Date.now() - start > timeout) {
+ clearInterval(tick);
+ reject(new Error(`waitFor timed out after ${timeout} ms`));
+ }
+ }, 30);
+ });
+}
+
+function shadowText(el) { return el.shadowRoot?.textContent ?? ''; }
+function shadowHTML(el) { return el.shadowRoot?.innerHTML ?? ''; }
+
+// Width of the first rendered route SVG. A branching EIP spreads its branches
horizontally, so the SVG is much
+// wider than the ~240px of a single linear column; this is the signal the
branching-layout tests assert on.
+function svgWidth(el) {
+ const svg = el.shadowRoot?.querySelector('svg');
+ return svg ? Number(svg.getAttribute('width')) : 0;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Mock route data
+ * ══════════════════════════════════════════════════════════════════════════
*/
+
+const MINIMAL = (routeId) => ({
+ routes: [{
+ routeId,
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer:tick]' },
+ { type: 'to', id: 't1', level: 1, code: 'to[log:out]' },
+ ],
+ }],
+});
+
+const STATS_DATA = {
+ routes: [{
+ routeId: 'stats-route',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer:tick]',
+ statistics: { exchangesTotal: 42, exchangesFailed: 3 } },
+ { type: 'to', id: 't1', level: 1, code: 'to[log:out]',
+ statistics: { exchangesTotal: 42, exchangesFailed: 0 } },
+ ],
+ }],
+};
+
+// description is 66 chars, well over the 28-char truncation limit
+const LONG_DESCRIPTION =
'this-is-a-deliberately-very-long-description-to-trigger-truncation';
+const LONG_DESC_DATA = {
+ routes: [{
+ routeId: 'long-route',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer]',
+ description: LONG_DESCRIPTION },
+ ],
+ }],
+};
+
+const CHOICE_DATA = {
+ routes: [{
+ routeId: 'choice-route',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer]' },
+ { type: 'choice', id: 'ch1', level: 1, code: 'choice' },
+ { type: 'when', id: 'wh1', level: 2, code: 'when[${body} !=
null]' },
+ { type: 'to', id: 't1', level: 3, code: 'to[direct:a]' },
+ { type: 'otherwise', id: 'ow1', level: 2, code: 'otherwise' },
+ { type: 'to', id: 't2', level: 3, code: 'to[direct:b]' },
+ ],
+ }],
+};
+
+const CIRCUIT_BREAKER_DATA = {
+ routes: [{
+ routeId: 'cb-route',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer]'
},
+ { type: 'circuitBreaker', id: 'cb1', level: 1, code:
'circuitBreaker' },
+ { type: 'to', id: 't1', level: 2, code:
'to[http:svc/api]' },
+ // onFallback is modeled as a `when` node: the component has no
dedicated onFallback type styling
+ { type: 'when', id: 'fb1', level: 2, code: 'onFallback'
},
+ { type: 'log', id: 'l1', level: 3, code:
'log[fallback]' },
+ ],
+ }],
+};
+
+// Fixtures for the remaining branching EIPs (multicast, doTry, loadBalance,
recipientList). Each has two or more
+// branches at the same level, which the layout engine must place
side-by-side. If a type were dropped from the
+// component's BRANCHING_EIPS set, its branches would collapse into a single
vertical column and the rendered SVG
+// would shrink to the linear width, which the layout tests below assert
against.
+const MULTICAST_DATA = {
+ routes: [{
+ routeId: 'multicast-route',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer]' },
+ { type: 'multicast', id: 'mc1', level: 1, code: 'multicast' },
+ { type: 'to', id: 't1', level: 2, code: 'to[direct:a]' },
+ { type: 'to', id: 't2', level: 2, code: 'to[direct:b]' },
+ { type: 'to', id: 't3', level: 2, code: 'to[direct:c]' },
+ ],
+ }],
+};
+
+const DOTRY_DATA = {
+ routes: [{
+ routeId: 'dotry-route',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer]' },
+ { type: 'doTry', id: 'dt1', level: 1, code: 'doTry' },
+ { type: 'to', id: 't1', level: 2, code: 'to[direct:work]'
},
+ { type: 'doCatch', id: 'dc1', level: 2, code:
'doCatch[Exception]' },
+ { type: 'log', id: 'l1', level: 3, code: 'log[caught]' },
+ { type: 'doFinally', id: 'df1', level: 2, code: 'doFinally' },
+ { type: 'log', id: 'l2', level: 3, code: 'log[cleanup]' },
+ ],
+ }],
+};
+
+const LOADBALANCE_DATA = {
+ routes: [{
+ routeId: 'loadbalance-route',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer]' },
+ { type: 'loadBalance', id: 'lb1', level: 1, code: 'loadBalance' },
+ { type: 'to', id: 't1', level: 2, code: 'to[direct:a]' },
+ { type: 'to', id: 't2', level: 2, code: 'to[direct:b]' },
+ ],
+ }],
+};
+
+const RECIPIENTLIST_DATA = {
+ routes: [{
+ routeId: 'recipientlist-route',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code: 'from[timer]'
},
+ { type: 'recipientList', id: 'rl1', level: 1, code:
'recipientList' },
+ { type: 'to', id: 't1', level: 2, code: 'to[direct:a]'
},
+ { type: 'to', id: 't2', level: 2, code: 'to[direct:b]'
},
+ ],
+ }],
+};
+
+const MULTI_ROUTE_DATA = {
+ routes: [
+ MINIMAL('route-alpha').routes[0],
+ MINIMAL('route-beta').routes[0],
+ MINIMAL('route-gamma').routes[0],
+ ],
+};
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Test suites
+ * ══════════════════════════════════════════════════════════════════════════
*/
+
+describe('Component Lifecycle', () => {
+
+ it('shows loading state while fetch is pending', () => {
+ mockFetch(() => new Promise(() => { /* never resolves */ }));
+ const el = mount({ src: '/mock/pending' });
+ // Loading state is rendered synchronously on connectedCallback
+ expect(shadowText(el)).toContain('Loading diagram');
+ el.remove();
+ });
+
+ it('shows error state on HTTP 5xx response', async () => {
+ mockFetch(() => Promise.resolve({ ok: false, status: 500, statusText:
'Internal Server Error' }));
+ const el = mount({ src: '/mock/broken' });
+ await waitFor(() => shadowHTML(el).includes('class="error"'));
+ expect(shadowText(el)).toContain('HTTP 500');
+ el.remove();
+ });
+
+ it('shows error state when JSON parsing fails', async () => {
+ mockFetch(() => Promise.resolve({
+ ok: true,
+ json: () => Promise.reject(new SyntaxError('Unexpected token')),
+ }));
+ const el = mount({ src: '/mock/bad-json' });
+ await waitFor(() => shadowHTML(el).includes('class="error"'));
+ expect(shadowText(el)).toBeTruthy();
+ el.remove();
+ });
+
+ it('shows error state when response is missing routes array', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve({ notRoutes: [] }) }));
+ const el = mount({ src: '/mock/no-routes' });
+ await waitFor(() => shadowHTML(el).includes('class="error"'));
+ expect(shadowText(el)).toContain('missing routes array');
+ el.remove();
+ });
+
+});
+
+describe('Rendering', () => {
+
+ it('renders an SVG for a simple two-node route', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(MINIMAL('simple')) }));
+ const el = mount({ src: '/mock/simple' });
+ await waitFor(() => shadowHTML(el).includes('<svg'));
+ expect(shadowHTML(el)).toContain('<svg');
+ el.remove();
+ });
+
+ it('renders aria-label for from and to nodes', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(MINIMAL('aria-test')) }));
+ const el = mount({ src: '/mock/aria' });
+ await waitFor(() => shadowHTML(el).includes('aria-label'));
+ expect(shadowHTML(el)).toContain('aria-label="from:');
+ expect(shadowHTML(el)).toContain('aria-label="to:');
+ el.remove();
+ });
+
+ it('displays the route label', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(MINIMAL('my-test-route')) }));
+ const el = mount({ src: '/mock/label' });
+ await waitFor(() => shadowText(el).includes('my-test-route'));
+ expect(shadowText(el)).toContain('my-test-route');
+ el.remove();
+ });
+
+ it('renders edge connectors between nodes', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(MINIMAL('edges')) }));
+ const el = mount({ src: '/mock/edges' });
+ await waitFor(() => shadowHTML(el).includes('<path'));
+ expect(shadowHTML(el)).toContain('<path');
+ el.remove();
+ });
+
+ it('displays exchange statistics when present', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(STATS_DATA) }));
+ const el = mount({ src: '/mock/stats' });
+ await waitFor(() => shadowText(el).includes('✓42'));
+ expect(shadowText(el)).toContain('✓42');
+ expect(shadowText(el)).toContain('✗3');
+ el.remove();
+ });
+
+ it('truncates long node descriptions with an ellipsis', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(LONG_DESC_DATA) }));
+ const el = mount({ src: '/mock/long' });
+ await waitFor(() => shadowHTML(el).includes('<svg'));
+ expect(shadowText(el)).not.toContain(LONG_DESCRIPTION);
+ expect(shadowText(el)).toContain('…');
+ el.remove();
+ });
+
+});
+
+describe('Attribute API', () => {
+
+ it('appends filter as a query parameter in the fetch URL', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(MINIMAL('filter-test')) }));
+ const el = mount({ src: '/mock/filter', filter: 'myRoute' });
+ await waitFor(() => fetchLog.length > 0);
+ expect(fetchLog[0]).toContain('filter=myRoute');
+ el.remove();
+ });
+
+ it('re-fetches and updates content when src attribute changes', async ()
=> {
+ let serveData = MINIMAL('route-alpha');
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(serveData) }));
+ const el = mount({ src: '/mock/src-a' });
+ await waitFor(() => shadowText(el).includes('route-alpha'));
+
+ serveData = MINIMAL('route-beta');
+ el.setAttribute('src', '/mock/src-b');
+ await waitFor(() => shadowText(el).includes('route-beta'));
+ expect(shadowText(el)).not.toContain('route-alpha');
+ el.remove();
+ });
+
+ it('renders all routes when response contains multiple routes', async ()
=> {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(MULTI_ROUTE_DATA) }));
+ const el = mount({ src: '/mock/multi' });
+ await waitFor(() => shadowText(el).includes('route-gamma'));
+ const cols = el.shadowRoot.querySelectorAll('.route-col');
+ expect(cols.length).toBe(3);
+ el.remove();
+ });
+
+});
+
+describe('EIP Node Types', () => {
+
+ it('renders choice, when, and otherwise nodes', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(CHOICE_DATA) }));
+ const el = mount({ src: '/mock/choice' });
+ await waitFor(() => shadowHTML(el).includes('aria-label="choice:'));
+ expect(shadowHTML(el)).toContain('aria-label="choice:');
+ expect(shadowHTML(el)).toContain('aria-label="when:');
+ expect(shadowHTML(el)).toContain('aria-label="otherwise:');
+ el.remove();
+ });
+
+ it('renders circuitBreaker node', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(CIRCUIT_BREAKER_DATA) }));
+ const el = mount({ src: '/mock/cb' });
+ await waitFor(() =>
shadowHTML(el).includes('aria-label="circuitBreaker:'));
+ expect(shadowHTML(el)).toContain('aria-label="circuitBreaker:');
+ el.remove();
+ });
+
+});
+
+describe('Branching EIP Layout', () => {
+
+ // Each branching EIP must place its branches side-by-side, producing an
SVG wider than a single linear column
+ // (~240px). A regression dropping the type from BRANCHING_EIPS would
stack the branches vertically and the
+ // width would collapse below this threshold.
+ const SIDE_BY_SIDE_MIN_WIDTH = 360;
+
+ it('multicast spreads its branches horizontally', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(MULTICAST_DATA) }));
+ const el = mount({ src: '/mock/multicast' });
+ await waitFor(() => shadowHTML(el).includes('aria-label="multicast:'));
+ expect(svgWidth(el)).toBeGreaterThan(SIDE_BY_SIDE_MIN_WIDTH);
+ el.remove();
+ });
+
+ it('doTry spreads its clauses horizontally', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(DOTRY_DATA) }));
+ const el = mount({ src: '/mock/dotry' });
+ await waitFor(() => shadowHTML(el).includes('aria-label="doTry:'));
+ expect(svgWidth(el)).toBeGreaterThan(SIDE_BY_SIDE_MIN_WIDTH);
+ el.remove();
+ });
+
+ it('loadBalance spreads its branches horizontally', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(LOADBALANCE_DATA) }));
+ const el = mount({ src: '/mock/loadbalance' });
+ await waitFor(() =>
shadowHTML(el).includes('aria-label="loadBalance:'));
+ expect(svgWidth(el)).toBeGreaterThan(SIDE_BY_SIDE_MIN_WIDTH);
+ el.remove();
+ });
+
+ it('recipientList spreads its branches horizontally', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(RECIPIENTLIST_DATA) }));
+ const el = mount({ src: '/mock/recipientlist' });
+ await waitFor(() =>
shadowHTML(el).includes('aria-label="recipientList:'));
+ expect(svgWidth(el)).toBeGreaterThan(SIDE_BY_SIDE_MIN_WIDTH);
+ el.remove();
+ });
+
+});
+
+describe('Node Colors', () => {
+
+ it('choice node uses amber fill (#f59e0b)', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(CHOICE_DATA) }));
+ const el = mount({ src: '/mock/choice-color' });
+ await waitFor(() => shadowHTML(el).includes('#f59e0b'));
+ expect(shadowHTML(el)).toContain('#f59e0b');
+ el.remove();
+ });
+
+ it('circuitBreaker node uses red fill (#ef4444)', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(CIRCUIT_BREAKER_DATA) }));
+ const el = mount({ src: '/mock/cb-color' });
+ await waitFor(() => shadowHTML(el).includes('#ef4444'));
+ expect(shadowHTML(el)).toContain('#ef4444');
+ el.remove();
+ });
+
+});
+
+describe('Polling', () => {
+
+ it('refresh attribute causes repeated fetch calls', async () => {
+ mockFetch(() => Promise.resolve({ ok: true, json: () =>
Promise.resolve(MINIMAL('poll')) }));
+ const el = mount({ src: '/mock/poll', refresh: '50' });
+ await new Promise(r => setTimeout(r, 250));
+ el.remove();
+ expect(fetchLog.length).toBeGreaterThan(2);
+ });
+
+});
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * Runner — renders results into the DOM
+ * ══════════════════════════════════════════════════════════════════════════
*/
+
+const SUITE_TAGS = {
+ 'Component Lifecycle': 'tag-lifecycle',
+ 'Rendering': 'tag-render',
+ 'Attribute API': 'tag-api',
+ 'EIP Node Types': 'tag-eip',
+ 'Branching EIP Layout': 'tag-eip',
+ 'Node Colors': 'tag-color',
+ 'Polling': 'tag-polling',
+};
+
+async function run() {
+ const resultsEl = document.getElementById('results');
+ const summaryEl = document.getElementById('summary');
+ let passed = 0, failed = 0;
+
+ for (const suite of suites) {
+ const section = document.createElement('section');
+ section.id = suite.label.toLowerCase().replace(/\s+/g, '-');
+ section.className = 'section';
+
+ const tagClass = SUITE_TAGS[suite.label] ?? 'tag-lifecycle';
+ section.innerHTML = `
+ <div class="section-header">
+ <h2>${suite.label}</h2>
+ <span class="section-tag ${tagClass}">${suite.tests.length}
test${suite.tests.length !== 1 ? 's' : ''}</span>
+ </div>
+ <div class="card"><ul class="test-list"></ul></div>`;
+ resultsEl.appendChild(section);
+ const list = section.querySelector('.test-list');
+
+ for (const test of suite.tests) {
+ const li = document.createElement('li');
+ li.className = 'test-row pending';
+ li.innerHTML = `<span class="badge run-badge">RUN</span><span
class="test-label">${test.label}</span>`;
+ list.appendChild(li);
+
+ // Reset shared state between tests
+ fetchLog = [];
+ mountEl.innerHTML = '';
+
+ try {
+ await test.fn();
+ li.className = 'test-row pass';
+ li.innerHTML = `<span class="badge
pass-badge">PASS</span><span class="test-label">${test.label}</span>`;
+ passed++;
+ } catch (err) {
+ li.className = 'test-row fail';
+ li.innerHTML = `<span class="badge
fail-badge">FAIL</span><span class="test-label">${test.label}</span><span
class="err-msg">${err.message}</span>`;
+ failed++;
+ }
+
+ // Update summary after each test
+ const total = passed + failed;
+ summaryEl.textContent = `${passed} / ${total} passed`;
+ summaryEl.className = failed > 0 ? 'has-fail' : 'running';
+ }
+ }
+
+ const total = passed + failed;
+ summaryEl.textContent = failed === 0
+ ? `All ${total} tests passed`
+ : `${passed} / ${total} passed — ${failed} failed`;
+ summaryEl.className = failed === 0 ? 'all-pass' : 'has-fail';
+}
+
+run();
+</script>
+</body>
+</html>
diff --git a/components/camel-diagram/src/test/resources/smoke-test.html
b/components/camel-diagram/src/test/resources/smoke-test.html
new file mode 100644
index 000000000000..2e9a79cce705
--- /dev/null
+++ b/components/camel-diagram/src/test/resources/smoke-test.html
@@ -0,0 +1,720 @@
+<!--
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE html>
+<!--
+ Local smoke test for the camel-route-diagram web component.
+
+ Browsers block ES module imports from file:// URLs (CORS/null-origin).
+ Serve from the src/ directory:
+
+ cd components/camel-diagram/src/
+ python3 -m http.server 8080
+ open http://localhost:8080/test/resources/smoke-test.html
+
+ All data is mocked — no running Camel instance needed.
+-->
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>camel-route-diagram · Smoke Test</title>
+ <script type="module">
+ import
'../../main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js';
+ </script>
+ <script>
+ /* eslint-disable */
+
+ /* ──────────────────────────────────────────────────────────────────────
+ * Mock data registry — each key is a URL path used as src="…" below.
+ * ──────────────────────────────────────────────────────────────────────
*/
+ const MOCK = {
+
+ /* 1. Simple route: used in the three theme-comparison cards */
+ '/mock/simple': {
+ routes: [{
+ routeId: 'demo-route',
+ from: 'timer:tick',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code:
'from[timer:tick]', uri: 'timer:tick' },
+ { type: 'log', id: 'l1', level: 1, code: 'log[Hello World]',
+ statistics: { exchangesTotal: 42, exchangesFailed: 1 } },
+ { type: 'choice', id: 'ch1', level: 1, code: 'choice' },
+ { type: 'when', id: 'wh1', level: 2, code: 'when[${body} !=
null]' },
+ { type: 'to', id: 't1', level: 3, code: 'to[mock:out]' },
+ { type: 'otherwise', id: 'ow1', level: 2, code: 'otherwise' },
+ { type: 'to', id: 't2', level: 3, code: 'to[mock:dead]' },
+ ],
+ }],
+ },
+
+ /* 2. Content-Based Router — choice with two when + otherwise */
+ '/mock/content-router': {
+ routes: [{
+ routeId: 'order-router',
+ from: 'platform-http:/api/orders',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code:
'from[platform-http:/api/orders]' },
+ { type: 'log', id: 'l1', level: 1, code: 'log[Order
received: ${body}]' },
+ { type: 'choice', id: 'ch1', level: 1, code: 'choice' },
+ { type: 'when', id: 'wh1', level: 2, code:
'when[${header.tier} == premium]' },
+ { type: 'to', id: 't1', level: 3, code:
'to[direct:premium-fulfillment]' },
+ { type: 'when', id: 'wh2', level: 2, code:
'when[${header.tier} == standard]' },
+ { type: 'to', id: 't2', level: 3, code:
'to[direct:standard-fulfillment]' },
+ { type: 'otherwise', id: 'ow1', level: 2, code: 'otherwise' },
+ { type: 'to', id: 't3', level: 3, code:
'to[direct:default-queue]' },
+ { type: 'log', id: 'l2', level: 1, code: 'log[Order
dispatched]' },
+ ],
+ }],
+ },
+
+ /* 3. Error Handling — doTry + choice inside + doCatch + doFinally */
+ '/mock/error-handling': {
+ routes: [{
+ routeId: 'safe-processor',
+ from: 'direct:process',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code:
'from[direct:process]' },
+ { type: 'setHeader', id: 'sh1', level: 1, code: 'setHeader[type]'
},
+ { type: 'doTry', id: 'dt1', level: 1, code: 'doTry' },
+ { type: 'choice', id: 'ch1', level: 2, code: 'choice' },
+ { type: 'when', id: 'wh1', level: 3, code:
'when[header(type) == A]' },
+ { type: 'log', id: 'la', level: 4, code: 'log[Type A
processing]' },
+ { type: 'otherwise', id: 'ow1', level: 3, code: 'otherwise' },
+ { type: 'log', id: 'lb', level: 4, code: 'log[Default
processing]' },
+ { type: 'doCatch', id: 'dc1', level: 2, code:
'doCatch[Exception]' },
+ { type: 'log', id: 'le', level: 3, code: 'log[Error:
${exception.message}]' },
+ { type: 'doFinally', id: 'df1', level: 2, code: 'doFinally' },
+ { type: 'log', id: 'lf', level: 3, code: 'log[Cleanup
resources]' },
+ { type: 'log', id: 'lend', level: 1, code: 'log[Processing
complete]' },
+ ],
+ }],
+ },
+
+ /* 4. Multicast — scatter to three parallel branches */
+ '/mock/multicast': {
+ routes: [{
+ routeId: 'order-fanout',
+ from: 'direct:orders',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code:
'from[direct:orders]' },
+ { type: 'log', id: 'l1', level: 1, code: 'log[Dispatching
order]' },
+ { type: 'multicast', id: 'mc1', level: 1, code: 'multicast' },
+ { type: 'to', id: 't1', level: 2, code:
'to[direct:billing]' },
+ { type: 'to', id: 't2', level: 2, code:
'to[direct:shipping]' },
+ { type: 'to', id: 't3', level: 2, code:
'to[direct:notifications]' },
+ { type: 'log', id: 'l2', level: 1, code: 'log[All branches
complete]' },
+ ],
+ }],
+ },
+
+ /* 5. Circuit Breaker — resilience4j with onFallback branch */
+ '/mock/circuit-breaker': {
+ routes: [{
+ routeId: 'resilient-http-call',
+ from: 'timer:probe',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code:
'from[timer:probe?period=5s]' },
+ { type: 'circuitBreaker', id: 'cb1', level: 1, code:
'circuitBreaker[resilience4j]' },
+ { type: 'to', id: 't1', level: 2, code:
'to[http:payment-svc/api/charge]' },
+ // onFallback is modeled as a `when` node — the component has no
dedicated onFallback type styling
+ { type: 'when', id: 'fb1', level: 2, code: 'onFallback'
},
+ { type: 'log', id: 'l1', level: 3, code: 'log[Service
unavailable]' },
+ { type: 'log', id: 'l2', level: 1, code: 'log[Result:
${body}]' },
+ ],
+ }],
+ },
+
+ /* 6. Exchange statistics — per-node exchangesTotal / exchangesFailed */
+ '/mock/metrics': {
+ routes: [{
+ routeId: 'event-consumer',
+ from: 'kafka:events',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1, code:
'from[kafka:events]',
+ statistics: { exchangesTotal: 18420, exchangesFailed: 0 } },
+ { type: 'log', id: 'l1', level: 1, code: 'log[Processing
event]',
+ statistics: { exchangesTotal: 18420, exchangesFailed: 0 } },
+ { type: 'choice', id: 'ch1', level: 1, code: 'choice' },
+ { type: 'when', id: 'wh1', level: 2, code:
'when[${header.priority} == high]',
+ statistics: { exchangesTotal: 4231, exchangesFailed: 0 } },
+ { type: 'to', id: 't1', level: 3, code:
'to[direct:priority-queue]',
+ statistics: { exchangesTotal: 4231, exchangesFailed: 0 } },
+ { type: 'otherwise', id: 'ow1', level: 2, code: 'otherwise',
+ statistics: { exchangesTotal: 14189, exchangesFailed: 32 } },
+ { type: 'to', id: 't2', level: 3, code:
'to[direct:standard-queue]',
+ statistics: { exchangesTotal: 14189, exchangesFailed: 32 } },
+ ],
+ }],
+ },
+
+ /* 7. Long URI — Kafka consumer with many query parameters */
+ '/mock/kafka-long-uri': {
+ routes: [{
+ routeId: 'kafka-consumer',
+ from: 'kafka:my-orders-topic',
+ code: [
+ { type: 'route', id: 'r0', level: 0, code: 'route' },
+ { type: 'from', id: 'f1', level: 1,
+ code:
'from[kafka:my-orders-topic?brokers=localhost:9092&groupId=order-consumer-group&autoOffsetReset=earliest]'
},
+ { type: 'log', id: 'l1', level: 1, code: 'log[Order received:
${body}]' },
+ { type: 'to', id: 't1', level: 1, code:
'to[direct:process-order]' },
+ ],
+ }],
+ },
+
+ /* 8. Multi-route — three-route order-processing pipeline */
+ '/mock/multi-route': {
+ routes: [
+ {
+ routeId: 'order-generator',
+ from: 'timer:orders',
+ code: [
+ { type: 'route', id: 'og-r0', level: 0, code: 'route' },
+ { type: 'from', id: 'og-f1', level: 1, code:
'from[timer:orders?period=5s]',
+ description: 'Generate test orders' },
+ { type: 'setBody', id: 'og-sb1', level: 1, code:
'setBody[constant(test-order)]',
+ description: 'Set payload' },
+ { type: 'to', id: 'og-t1', level: 1, code:
'to[direct:process-order]',
+ description: 'Hand off for processing' },
+ ],
+ },
+ {
+ routeId: 'order-processor',
+ from: 'direct:process-order',
+ code: [
+ { type: 'route', id: 'op-r0', level: 0, code: 'route' },
+ { type: 'from', id: 'op-f1', level: 1, code:
'from[direct:process-order]' },
+ { type: 'log', id: 'op-l1', level: 1, code:
'log[Processing: ${body}]' },
+ { type: 'choice', id: 'op-ch1', level: 1, code: 'choice' },
+ { type: 'when', id: 'op-wh1', level: 2, code:
'when[${header.valid}]' },
+ { type: 'to', id: 'op-t1', level: 3, code:
'to[direct:validate-order]' },
+ { type: 'otherwise', id: 'op-ow1', level: 2, code: 'otherwise' },
+ { type: 'to', id: 'op-t2', level: 3, code:
'to[mock:dead-letter]' },
+ ],
+ },
+ {
+ routeId: 'order-validator',
+ from: 'direct:validate-order',
+ code: [
+ { type: 'route', id: 'ov-r0', level: 0, code: 'route' },
+ { type: 'from', id: 'ov-f1', level: 1, code:
'from[direct:validate-order]' },
+ { type: 'log', id: 'ov-l1', level: 1, code: 'log[Validating
order]' },
+ { type: 'to', id: 'ov-t1', level: 1, code:
'to[kafka:validated-orders]' },
+ ],
+ },
+ ],
+ },
+ };
+
+ window.fetch = url => {
+ const path = new URL(url, location.href).pathname;
+ const data = MOCK[path];
+ if (!data) return Promise.resolve({ ok: false, status: 404, statusText:
'Not Found' });
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(data) });
+ };
+ </script>
+ <style>
+ /* ─── Camel design tokens (mirrored from camel.apache.org) ─── */
+ :root {
+ --camel-orange: #e97826;
+ --camel-orange-dark: #cf7428;
+ --camel-navy: #303284;
+ --camel-purple: #4f51ae;
+ --camel-light: #f5f5f5;
+ --camel-border: #e1e1e1;
+ --camel-text: #333;
+ --camel-muted: #5d5d5d;
+ --camel-white: #fff;
+ --radius: 6px;
+ }
+
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+ html { scroll-behavior: smooth; }
+
+ body {
+ font-family: system-ui, 'Open Sans', sans-serif;
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--camel-text);
+ background: var(--camel-light);
+ min-height: 100vh;
+ }
+
+ /* ─── Top nav bar ─── */
+ .site-nav {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: var(--camel-navy);
+ color: var(--camel-white);
+ height: 52px;
+ display: flex;
+ align-items: center;
+ padding: 0 1.5rem;
+ gap: 1rem;
+ box-shadow: 0 2px 10px rgba(0,0,0,.35);
+ }
+ .site-nav .logo-mark {
+ width: 30px;
+ height: 30px;
+ background: var(--camel-orange);
+ border-radius: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 800;
+ font-size: 16px;
+ flex-shrink: 0;
+ letter-spacing: -1px;
+ }
+ .site-nav .brand-name { font-weight: 700; font-size: .95rem; }
+ .site-nav .brand-sub { font-size: .7rem; opacity: .65; }
+ .site-nav .nav-spacer { flex: 1; }
+ .site-nav .nav-links { display: flex; gap: 1.25rem; }
+ .site-nav .nav-links a {
+ color: rgba(255,255,255,.75);
+ text-decoration: none;
+ font-size: .8rem;
+ font-weight: 600;
+ transition: color .15s;
+ }
+ .site-nav .nav-links a:hover { color: var(--camel-orange); }
+ .badge-pill {
+ background: var(--camel-orange);
+ color: var(--camel-white);
+ font-size: .65rem;
+ font-weight: 700;
+ padding: 2px 9px;
+ border-radius: 99px;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+ }
+
+ /* ─── Hero ─── */
+ .hero {
+ background: var(--camel-navy);
+ color: var(--camel-white);
+ padding: 2.5rem 1.5rem 2.75rem;
+ border-bottom: 4px solid var(--camel-orange);
+ }
+ .hero-inner { max-width: 900px; margin: 0 auto; }
+ .hero h1 {
+ font-size: 1.75rem;
+ font-weight: 800;
+ margin-bottom: .4rem;
+ letter-spacing: -.5px;
+ }
+ .hero h1 code {
+ color: var(--camel-orange);
+ font-family: 'Courier New', monospace;
+ font-size: 1.55rem;
+ }
+ .hero p {
+ opacity: .8;
+ max-width: 640px;
+ font-size: .9rem;
+ margin-bottom: .75rem;
+ }
+ .hero-hint {
+ display: inline-block;
+ background: rgba(255,255,255,.07);
+ border: 1px solid rgba(255,255,255,.15);
+ border-radius: var(--radius);
+ padding: .5rem .9rem;
+ font-family: 'Courier New', monospace;
+ font-size: .78rem;
+ color: #9db8d2;
+ line-height: 1.7;
+ }
+
+ /* ─── Layout ─── */
+ .content {
+ max-width: 1140px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 3rem;
+ }
+
+ /* ─── Sections ─── */
+ .section { margin-bottom: 3rem; }
+
+ .section-header {
+ display: flex;
+ align-items: center;
+ gap: .65rem;
+ margin-bottom: 1rem;
+ padding-bottom: .6rem;
+ border-bottom: 2px solid var(--camel-border);
+ }
+ .section-header h2 {
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: var(--camel-navy);
+ }
+ .section-tag {
+ font-size: .65rem;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 99px;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ }
+ .tag-theme { background: #e8eaf6; color: #303284; }
+ .tag-eip { background: #fff3e0; color: #9a4200; }
+ .tag-error { background: #fce4ec; color: #880e4f; }
+ .tag-resilience { background: #fbe9e7; color: #b71c1c; }
+ .tag-metrics { background: #e8f5e9; color: #1b5e20; }
+ .tag-kafka { background: #f3e5f5; color: #4a148c; }
+ .tag-multi { background: #e3f2fd; color: #0d47a1; }
+
+ /* ─── Cards ─── */
+ .card {
+ background: var(--camel-white);
+ border: 1px solid var(--camel-border);
+ border-top: 3px solid var(--camel-orange);
+ border-radius: var(--radius);
+ overflow: hidden;
+ }
+ .card-header {
+ padding: .7rem 1rem .55rem;
+ border-bottom: 1px solid var(--camel-border);
+ }
+ .card-header h3 {
+ font-size: .9rem;
+ font-weight: 700;
+ color: var(--camel-navy);
+ margin-bottom: .15rem;
+ }
+ .card-header h3 code {
+ font-size: .85rem;
+ background: #f0f0f0;
+ border-radius: 3px;
+ padding: 0 3px;
+ }
+ .card-header p {
+ font-size: .78rem;
+ color: var(--camel-muted);
+ }
+ .card-body { padding: 1rem; }
+ .card-body.dark-bg { background: #0f172a; padding: .75rem; }
+
+ /* ─── Grids ─── */
+ .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;
}
+ .grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;
}
+
+ /* ─── Scrollable diagrams ─── */
+ .diagram-scroll { overflow-x: auto; }
+
+ /* ─── Footer ─── */
+ .site-footer {
+ background: var(--camel-navy);
+ color: rgba(255,255,255,.55);
+ text-align: center;
+ padding: 1.5rem;
+ font-size: .78rem;
+ }
+ .site-footer a { color: var(--camel-orange); text-decoration: none; }
+ .site-footer a:hover { text-decoration: underline; }
+
+ /* ─── Responsive ─── */
+ @media (max-width: 800px) {
+ .grid-3, .grid-2 { grid-template-columns: 1fr; }
+ }
+ </style>
+</head>
+<body>
+
+<!-- ─── Navigation ─── -->
+<nav class="site-nav" aria-label="Site navigation">
+ <div class="logo-mark" aria-hidden="true">C</div>
+ <div>
+ <div class="brand-name">Apache Camel</div>
+ <div class="brand-sub">camel-route-diagram</div>
+ </div>
+ <div class="nav-spacer"></div>
+ <nav class="nav-links" aria-label="Jump to section">
+ <a href="#themes">Themes</a>
+ <a href="#eips">EIPs</a>
+ <a href="#error">Error Handling</a>
+ <a href="#resilience">Resilience</a>
+ <a href="#metrics">Metrics</a>
+ <a href="#kafka">Long URI</a>
+ <a href="#multi">Multi-Route</a>
+ </nav>
+ <span class="badge-pill">Smoke Test</span>
+</nav>
+
+<!-- ─── Hero ─── -->
+<header class="hero">
+ <div class="hero-inner">
+ <h1><code><camel-route-diagram></code></h1>
+ <p>
+ A local verification page for the route-diagram web component. Each
section exercises a different
+ EIP pattern, rendering feature, or edge case. All data is mocked — no
running Camel instance required.
+ </p>
+ <span class="hero-hint">
+ cd components/camel-diagram/src/<br>
+ python3 -m http.server 8080 && open
http://localhost:8080/test/resources/smoke-test.html
+ </span>
+ </div>
+</header>
+
+<!-- ─── Main content ─── -->
+<main class="content">
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 1 — Theme variants
+ ══════════════════════════════════════════════════════ -->
+ <section id="themes" class="section">
+ <div class="section-header">
+ <h2>Theme Variants</h2>
+ <span class="section-tag tag-theme">Theming</span>
+ </div>
+ <div class="grid-3">
+
+ <div class="card">
+ <div class="card-header">
+ <h3>Auto (OS preference)</h3>
+ <p>
+ Follows <code>prefers-color-scheme</code>. No <code>--crd-*</code>
variables
+ are set — the component picks light or dark based on your OS
setting.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/simple"></camel-route-diagram>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-header">
+ <h3>Light (forced)</h3>
+ <p>
+ Override via inline <code>style="--crd-bg:#fff; --crd-fg:#1e293b;
--crd-edge:#94a3b8"</code>
+ to pin the light palette regardless of OS setting.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/simple"
+ style="--crd-bg:#ffffff; --crd-fg:#1e293b; --crd-edge:#94a3b8;">
+ </camel-route-diagram>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-header">
+ <h3>Dark (forced)</h3>
+ <p>
+ Override via <code>style="--crd-bg:#0f172a; --crd-fg:#e2e8f0;
--crd-edge:#475569"</code>
+ to pin the dark palette regardless of OS setting.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll dark-bg">
+ <camel-route-diagram src="/mock/simple"
+ style="--crd-bg:#0f172a; --crd-fg:#e2e8f0; --crd-edge:#475569;">
+ </camel-route-diagram>
+ </div>
+ </div>
+
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 2 — Content-Based Router
+ ══════════════════════════════════════════════════════ -->
+ <section id="eips" class="section">
+ <div class="section-header">
+ <h2>Content-Based Router</h2>
+ <span class="section-tag tag-eip">EIP · choice</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>Order Router — <code>choice</code> with two <code>when</code> +
<code>otherwise</code></h3>
+ <p>
+ Routes incoming HTTP orders to premium, standard, or default
fulfillment channels based on a
+ <code>${header.tier}</code> value. Verifies that three side-by-side
branches render correctly
+ and that the post-choice <code>log</code> node reconnects from the
<code>choice</code> merge point.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/content-router"></camel-route-diagram>
+ </div>
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 3 — Error Handling
+ ══════════════════════════════════════════════════════ -->
+ <section id="error" class="section">
+ <div class="section-header">
+ <h2>Error Handling</h2>
+ <span class="section-tag tag-error">EIP · doTry · doCatch ·
doFinally</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>Safe Processor — <code>doTry</code> wrapping a <code>choice</code>,
+ with <code>doCatch</code> and <code>doFinally</code></h3>
+ <p>
+ A <code>choice</code> inside a <code>doTry</code>: the doTry acts as
a branching EIP so its three
+ clauses (<em>try block</em>, <em>doCatch</em>, <em>doFinally</em>)
are laid side-by-side.
+ The post-try <code>log</code> node reconnects from the
<code>doTry</code> merge point.
+ Mirrors the
<code>testChoiceInsideDoTryNoSpuriousMergeConnection</code> layout-engine test.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/error-handling"></camel-route-diagram>
+ </div>
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 4 — Scatter-Gather & Circuit Breaker
+ ══════════════════════════════════════════════════════ -->
+ <section id="resilience" class="section">
+ <div class="section-header">
+ <h2>Scatter-Gather & Resilience</h2>
+ <span class="section-tag tag-eip">EIP · multicast</span>
+ <span class="section-tag tag-resilience">EIP · circuitBreaker</span>
+ </div>
+ <div class="grid-2">
+
+ <div class="card">
+ <div class="card-header">
+ <h3>Order Fan-out — <code>multicast</code> to three branches</h3>
+ <p>
+ Sends each order simultaneously to billing, shipping, and
notification routes.
+ Verifies that three <code>to</code> nodes are placed side-by-side
and that the
+ post-multicast <code>log</code> reconnects from the
<code>multicast</code> merge point.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/multicast"></camel-route-diagram>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-header">
+ <h3>Resilient HTTP Call — <code>circuitBreaker</code> with
fallback</h3>
+ <p>
+ Protects a downstream payment-service call with Resilience4j. The
<code>onFallback</code>
+ branch is laid side-by-side with the main call. The final
<code>log</code> reconnects
+ from the <code>circuitBreaker</code> merge point.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram
src="/mock/circuit-breaker"></camel-route-diagram>
+ </div>
+ </div>
+
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 5 — Exchange Statistics
+ ══════════════════════════════════════════════════════ -->
+ <section id="metrics" class="section">
+ <div class="section-header">
+ <h2>Exchange Statistics</h2>
+ <span class="section-tag tag-metrics">Metrics</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>High-Throughput Event Consumer — per-node
<code>exchangesTotal</code> / <code>exchangesFailed</code></h3>
+ <p>
+ When a node has a <code>statistics</code> object the component
renders
+ <em>✓ successes / ✗ failures</em> beneath the
label.
+ Here the <code>otherwise</code> branch has accumulated 32 failures
out of 14 189
+ exchanges — spot the error rate at a glance.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/metrics"></camel-route-diagram>
+ </div>
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 6 — Long URI wrapping
+ ══════════════════════════════════════════════════════ -->
+ <section id="kafka" class="section">
+ <div class="section-header">
+ <h2>Long URI — Text Wrapping</h2>
+ <span class="section-tag tag-kafka">Kafka</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>Kafka Consumer — multi-option URI wrapped inside the node box</h3>
+ <p>
+ A <code>from</code> node with a long Kafka URI
+ (<code>brokers=…&groupId=…&autoOffsetReset=…</code>)
verifies that the label
+ wraps gracefully inside the fixed-width box without overflowing or
truncating silently.
+ Mirrors <code>testTextWrappingLongLabel</code>.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/kafka-long-uri"></camel-route-diagram>
+ </div>
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 7 — Multi-route pipeline
+ ══════════════════════════════════════════════════════ -->
+ <section id="multi" class="section">
+ <div class="section-header">
+ <h2>Multi-Route — Order-Processing Pipeline</h2>
+ <span class="section-tag tag-multi">Multiple Routes</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>Three-route pipeline rendered from a single <code>{ routes: […]
}</code> response</h3>
+ <p>
+ The <strong>generator</strong> fires a timer and hands off to
<strong>processor</strong>
+ (which routes valid/invalid orders via <code>choice</code>), which
in turn delegates valid
+ orders to <strong>validator</strong> for publication to Kafka.
+ Nodes in the generator route use the <code>description</code> field
for human-readable labels.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/multi-route"></camel-route-diagram>
+ </div>
+ </div>
+ </section>
+
+</main>
+
+<!-- ─── Footer ─── -->
+<footer class="site-footer">
+ <p>
+ <a href="https://camel.apache.org/" rel="noopener noreferrer">Apache
Camel</a>
+ ·
+ <code>camel-route-diagram</code> web component
+ ·
+ Local smoke test — not a production page.
+ </p>
+</footer>
+
+</body>
+</html>
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
index d0ca5c065982..762f7f8a4340 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
@@ -2008,7 +2008,7 @@ As a consequence, the generated Endpoint DSL header
accessors on
**Header Override Options (Hardening)**
-Added new configuration options to `camel-mail` to provide finer control over
message header overrides for security and hardening purposes.
+Added new configuration options to `camel-mail` to provide finer control over
message header overrides for security and hardening purposes.
The following options have been added to the mail endpoint:
@@ -2546,3 +2546,18 @@ resolved from the classpath via the component's
`URIResolver` and are therefore
If your Schematron rules legitimately reference an external DTD, external
entity, or external stylesheet,
those references will no longer be resolved and rule compilation will fail;
inline the referenced content
instead.
+
+=== camel-diagram: Embeddable web component
+
+A new `<camel-route-diagram>` web component is now bundled inside
`camel-diagram.jar`
+at `META-INF/resources/camel/diagram/camel-route-diagram.js`.
+Any Servlet 3 container (including Quarkus and Spring Boot) serving the JAR
automatically
+exposes the script as a static resource.
+
+The component consumes the existing `route-structure` dev console JSON
endpoint and renders
+routes as interactive SVG diagrams with optional per-processor metric overlays
and configurable
+periodic refresh.
+It is theme-agnostic, respects `prefers-color-scheme` for automatic dark/light
mode, and
+exposes CSS custom properties for full visual control.
+
+See the `camel-diagram` component documentation for usage instructions and
theming options.