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 2af9badb96fb CAMEL-23636: camel-diagram - fix diagram text overflow,
dark-theme contrast, add smoke pages (#24275)
2af9badb96fb is described below
commit 2af9badb96fb949f64a34de299ae041b877e9b77
Author: Adriano Machado <[email protected]>
AuthorDate: Sat Jun 27 14:02:42 2026 -0400
CAMEL-23636: camel-diagram - fix diagram text overflow, dark-theme
contrast, add smoke pages (#24275)
* CAMEL-23636: camel-diagram - fix diagram text overflow, dark-theme
contrast, add smoke pages
- Replace fixed char-count truncation with canvas measureText width-aware
fitText in both camel-route-diagram and camel-topology-diagram, so long
labels trim to a clean ellipsis instead of overflowing the node box.
- Topology: reserve space for the metric badge and add a native SVG <title>
tooltip showing the full label/URI when the text is trimmed.
- Fix dark-theme node-box contrast in camel-topology-diagram by introducing
a --ctd-node-bg token that the dark media query flips together with the
text color (mirrors the existing --crd-node-bg pattern in route-diagram).
- Add a camel-topology-diagram smoke-test page and rename smoke-test.html to
smoke-test-route.html for consistency.
- Add a Light/Dark/OS header theme switch (default OS) to both smoke pages.
- Update integration-test.html links to the renamed and new smoke pages.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* CAMEL-23636: camel-diagram - use configured fonts in measurements
Measure labels with the active --crd-font and --ctd-font values, let
topology badges reserve enough space for large counters, and move the font
smoke cases into the theming sections.
Co-authored-by: OpenAI Codex <[email protected]>
* CAMEL-23636: camel-diagram - document topology component and update smoke
tests
Document the topology web component, dev-console topology mode, and CSS
theming. Move the custom-font demo to its own row and use Courier New so
text measurement matches the rendered SVG font.
Co-authored-by: Cursor <[email protected]>
* CAMEL-23636: camel-diagram - document --crd-node-bg/--ctd-node-bg theming
properties
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: OpenAI Codex <[email protected]>
Co-authored-by: Cursor <[email protected]>
---
.../camel-diagram/src/main/docs/diagram.adoc | 98 ++-
.../resources/camel/diagram/camel-route-diagram.js | 37 +-
.../camel/diagram/camel-topology-diagram.js | 73 ++-
.../camel/diagram/WebComponentBundleTest.java | 27 +
.../src/test/resources/integration-test.html | 5 +-
.../{smoke-test.html => smoke-test-route.html} | 96 ++-
.../src/test/resources/smoke-test-topology.html | 669 +++++++++++++++++++++
7 files changed, 963 insertions(+), 42 deletions(-)
diff --git a/components/camel-diagram/src/main/docs/diagram.adoc
b/components/camel-diagram/src/main/docs/diagram.adoc
index 414231173fd2..71a4bf1d15fd 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -113,9 +113,15 @@ camel cmd route-diagram MyRoute.java
=== Developer console
When the developer console is enabled (for example with `camel run
--console`), the `route-diagram`
-console renders the running routes using the interactive
`<camel-route-diagram>` web component,
-fed by the `route-structure` console. Request `?format=png` to get the legacy
inline PNG image
-instead, or use the `ascii` / `unicode` themes for plain-text output.
+console provides two diagram modes:
+
+* **Route mode** (default): Renders individual route diagrams using the
`<camel-route-diagram>` web component,
+ fed by the `route-structure` console. Shows the step-by-step flow within
each route.
+* **Topology mode**: Renders inter-route topology using the
`<camel-topology-diagram>` web component,
+ fed by the `route-topology` console. Shows how routes connect through shared
endpoints.
+
+Access topology mode with `?mode=topology`. For both modes, request
`?format=png` to get the legacy
+inline PNG image instead, or use the `ascii` / `unicode` themes for plain-text
output.
== Color Themes
@@ -284,14 +290,24 @@ RouteDiagramRenderer pngRenderer = new
RouteDiagramRenderer(nodeWidth, fontSize)
BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight,
colors, highlightedNodes, style);
----
-== Embeddable Web Component
+== Embeddable Web Components
+
+`camel-diagram` ships two lightweight web components that render interactive
diagrams
+as SVG directly in the browser. Any application with `camel-diagram` on the
classpath
+automatically serves the components as static resources — no extra server
configuration needed.
+
+=== Route Diagram 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.
+The `<camel-route-diagram>` web component renders individual route diagrams
showing
+the step-by-step flow within a single route, including EIP patterns,
processors, and endpoints.
-=== Usage
+=== Topology Diagram Component
+
+The `<camel-topology-diagram>` web component renders inter-route topology
diagrams showing
+how multiple routes connect to each other through shared endpoints, providing
a high-level
+view of message flow across the entire application.
+
+=== Route Diagram 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):
@@ -312,7 +328,28 @@ The `src` attribute must point to an endpoint returning
the `route-structure` de
The component automatically appends `?metric=true` so that per-processor
exchange statistics
are included in the diagram.
-=== Attributes
+=== Topology Diagram Usage
+
+Include the bundled script served from
`META-INF/resources/camel/diagram/camel-topology-diagram.js`:
+
+[source,html]
+----
+<script type="module" src="/camel/diagram/camel-topology-diagram.js"></script>
+
+<camel-topology-diagram
+ src="/q/dev/route-topology"
+ refresh="5000"
+ external="true"
+ interlink="true"
+ metric="true">
+</camel-topology-diagram>
+----
+
+The `src` attribute must point to an endpoint returning the `route-topology`
dev console JSON
+(for example the Quarkus Dev UI endpoint `/q/dev/route-topology`).
+This provides a high-level view showing how routes connect through shared
endpoints.
+
+=== Route Diagram Attributes
[width="100%",cols="2,5,2",options="header"]
|===
@@ -322,16 +359,31 @@ are included in the diagram.
| `filter` | Route ID filter, forwarded as `?filter=` query parameter | (all
routes)
|===
+=== Topology Diagram Attributes
+
+[width="100%",cols="2,5,2",options="header"]
+|===
+| Attribute | Description | Default
+| `src` | URL to fetch the route-topology JSON from (required) | —
+| `refresh` | Polling interval in milliseconds; `0` disables polling | `0`
+| `external` | Include external endpoints as boundary nodes | `false`
+| `interlink` | Collapse shared endpoints into intermediate nodes | `false`
+| `metric` | Include exchange counters from route metrics | `false`
+|===
+
=== 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:
+Both components are theme-agnostic.
+They respect `prefers-color-scheme` automatically for dark/light mode,
+and expose CSS custom properties so the host application can override every
visual aspect.
+
+==== Route Diagram Theming
[source,css]
----
camel-route-diagram {
--crd-bg: #ffffff; /* canvas background */
+ --crd-node-bg: #ffffff; /* node fill colour (defaults to
--crd-bg) */
--crd-fg: #1e293b; /* text colour */
--crd-edge: #94a3b8; /* edge/arrow colour */
--crd-stat: #64748b; /* metric overlay text */
@@ -352,3 +404,23 @@ camel-route-diagram {
--crd-color-default: #6366f1; /* all other EIP nodes */
}
----
+
+==== Topology Diagram Theming
+
+[source,css]
+----
+camel-topology-diagram {
+ --ctd-bg: #ffffff; /* canvas background */
+ --ctd-node-bg: #ffffff; /* node fill colour (defaults to
--ctd-bg) */
+ --ctd-fg: #1e293b; /* text colour */
+ --ctd-edge: #94a3b8; /* edge/arrow colour */
+ --ctd-stat: #64748b; /* metric overlay text */
+ --ctd-font: system-ui; /* font family */
+ --ctd-font-size: 12px; /* base font size */
+ --ctd-color-route: #6366f1; /* route nodes */
+ --ctd-color-trigger: #f59e0b; /* trigger/timer routes */
+ --ctd-color-external-in: #6b7280; /* external input endpoints */
+ --ctd-color-external-out: #6b7280; /* external output endpoints */
+ --ctd-color-external-shared: #8b5cf6; /* shared external endpoints */
+}
+----
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
index 48724cae91cd..8515bd37d771 100644
---
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
@@ -219,10 +219,29 @@ function nodeColor(type) {
return TYPE_COLORS[type] ?? 'var(--crd-color-default, #6366f1)';
}
-function truncate(text, maxLen = 28) {
+// Trim a label so it fits a pixel width. SVG <text> is proportional, so a
fixed character budget cannot
+// guarantee a label stays inside its node; measure candidate strings with an
offscreen 2D canvas (same font
+// the SVG inherits) and drop trailing characters until the text plus an
ellipsis fits. Falls back to a coarse
+// character budget when a 2D canvas context is unavailable.
+let measureCtx = null;
+function fitText(text, maxWidth, fontSize = 11, fontFamily = 'system-ui,
sans-serif') {
if (!text) return '';
- const clean = text.replace(/^\.+/, '');
- return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '\u2026' :
clean;
+ let s = String(text).replace(/^\.+/, '');
+ if (measureCtx === null) {
+ measureCtx = document.createElement('canvas').getContext('2d') ||
false;
+ }
+ if (!measureCtx) {
+ const maxLen = Math.max(1, Math.floor(maxWidth / (fontSize * 0.6)));
+ return s.length > maxLen ? s.slice(0, maxLen - 1) + '\u2026' : s;
+ }
+ measureCtx.font = `${fontSize}px ${fontFamily}`;
+ if (measureCtx.measureText(s).width <= maxWidth) {
+ return s;
+ }
+ while (s.length > 0 && measureCtx.measureText(s + '\u2026').width >
maxWidth) {
+ s = s.slice(0, -1);
+ }
+ return s.replace(/\s+$/, '') + '\u2026';
}
function formatStat(stats) {
@@ -423,6 +442,7 @@ class CamelRouteDiagram extends HTMLElement {
#routeHTML(route, routeIdx) {
const { positions, width, height } = layoutRoute(route);
+ const fontFamily =
getComputedStyle(this).getPropertyValue('--crd-font').trim() || 'system-ui,
sans-serif';
const ids = Object.keys(positions);
const pfx = `t${this.#uid}r${routeIdx}`;
const defs = ids.map(id => {
@@ -436,7 +456,7 @@ class CamelRouteDiagram extends HTMLElement {
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('')}
+ ${ids.map(id => this.#nodeHTML(positions[id], `${pfx}${safeId(id)}`,
fontFamily)).join('')}
</svg>
</div>`;
}
@@ -469,15 +489,20 @@ class CamelRouteDiagram extends HTMLElement {
fill="var(--crd-edge, #94a3b8)"/>`;
}
- #nodeHTML(pos, clipId) {
- const label = truncate(pos.description ?? pos.code);
+ #nodeHTML(pos, clipId, fontFamily) {
+ // Label starts 30px in (icon zone); keep an 8px margin before the
right border.
+ const full = pos.description ?? pos.code;
+ const label = fitText(full, NODE_W - 38, 11, fontFamily);
const stat = formatStat(pos.statistics);
const fill = nodeColor(pos.type);
const textX = pos.x + 30;
const textY = pos.y + NODE_H / 2 + 4;
+ // Only attach a hover tooltip when the label was actually trimmed
(ends with the ellipsis).
+ const title = label.endsWith('…') ? `<title>${esc(full)}</title>` :
'';
return `
<g role="img" aria-label="${esc(pos.type)}: ${esc(label)}">
+ ${title}
<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}"
diff --git
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
index 393e6443a6b4..f45c55b0bdd5 100644
---
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
+++
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
@@ -275,10 +275,35 @@ function isExternal(nodeType) {
return nodeType === 'external-in' || nodeType === 'external-out' ||
nodeType === 'external';
}
-function truncate(text, maxLen = 28) {
+// Measure text with an offscreen 2D canvas using the same font the SVG
inherits. Returns null when no canvas
+// context is available so callers can fall back to a character-count estimate.
+let measureCtx = null;
+function measureWidth(text, fontSize, fontFamily = 'system-ui, sans-serif') {
+ if (measureCtx === null) {
+ measureCtx = document.createElement('canvas').getContext('2d') ||
false;
+ }
+ if (!measureCtx) return null;
+ measureCtx.font = `${fontSize}px ${fontFamily}`;
+ return measureCtx.measureText(String(text)).width;
+}
+
+// Trim a label so it fits a pixel width. SVG <text> is proportional, so a
fixed character budget cannot
+// guarantee a label stays inside its node; drop trailing characters until the
text plus an ellipsis fits.
+// When `paren` is set the surrounding parentheses are part of the measurement
so the ellipsis lands inside them.
+function fitText(text, maxWidth, fontSize = 11, paren = false, fontFamily =
'system-ui, sans-serif') {
if (!text) return '';
- const clean = text.replace(/^\.+/, '');
- return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
+ const wrap = paren ? (v) => `(${v})` : (v) => v;
+ let s = String(text).replace(/^\.+/, '');
+ let w = measureWidth(wrap(s), fontSize, fontFamily);
+ if (w === null) {
+ const maxLen = Math.max(1, Math.floor(maxWidth / (fontSize * 0.6)) -
(paren ? 2 : 0));
+ return s.length > maxLen ? wrap(s.slice(0, maxLen - 1) + '…') :
wrap(s);
+ }
+ if (w <= maxWidth) return wrap(s);
+ while (s.length > 0 && measureWidth(wrap(s + '…'), fontSize, fontFamily) >
maxWidth) {
+ s = s.slice(0, -1);
+ }
+ return wrap(s.replace(/\s+$/, '') + '…');
}
function esc(s) {
@@ -306,10 +331,15 @@ const COMPONENT_STYLE = `
align-items: flex-start;
justify-content: center;
background: var(--ctd-bg, transparent);
+ /* Node-box fill tracks the theme so light text never lands on a light box
(and vice versa). */
+ --ctd-node-bg: var(--ctd-bg, #ffffff);
padding: 12px;
}
@media (prefers-color-scheme: dark) {
- .wrap { background: var(--ctd-bg, #0f172a); }
+ .wrap {
+ background: var(--ctd-bg, #0f172a);
+ --ctd-node-bg: var(--ctd-bg, #0f172a);
+ }
}
.error { color: #ef4444; padding: 8px; }
.loading { opacity: .6; padding: 8px; }
@@ -348,7 +378,7 @@ function nodeColor(nodeType) {
* interlink - show intermediary nodes for routes connected via shared
externals (default: true)
*
* CSS custom properties (all optional):
- * --ctd-bg, --ctd-fg, --ctd-edge, --ctd-font, --ctd-font-size
+ * --ctd-bg, --ctd-node-bg, --ctd-fg, --ctd-edge, --ctd-font, --ctd-font-size
* --ctd-color-route, --ctd-color-trigger, --ctd-color-external
*
* @since 4.21
@@ -471,6 +501,7 @@ class CamelTopologyDiagram extends HTMLElement {
#topologyHTML() {
const data = this.#data;
+ const fontFamily =
getComputedStyle(this).getPropertyValue('--ctd-font').trim() || 'system-ui,
sans-serif';
// Parse nodes and edges
const nodes = (data.nodes ?? []).map(n => ({
@@ -500,7 +531,7 @@ class CamelTopologyDiagram extends HTMLElement {
if (!layoutNodes.length) return '<p class="loading">No routes</p>';
const edgeSvg = layoutEdges.map(e => this.#edgeHTML(e)).join('');
- const nodeSvg = layoutNodes.map(n => this.#nodeHTML(n)).join('');
+ const nodeSvg = layoutNodes.map(n => this.#nodeHTML(n,
fontFamily)).join('');
return `<svg width="${totalWidth}" height="${totalHeight}" viewBox="0
0 ${totalWidth} ${totalHeight}"
aria-label="Route topology diagram">
@@ -532,17 +563,36 @@ class CamelTopologyDiagram extends HTMLElement {
fill="var(--ctd-edge, #94a3b8)"/>`;
}
- #nodeHTML(node) {
+ #nodeHTML(node, fontFamily) {
const ext = isExternal(node.nodeType);
const fill = nodeColor(node.nodeType);
const dashAttr = ext ? ' stroke-dasharray="4 3"' : '';
- const label = ext ? truncate(node.from, 28) :
truncate(node.description ?? node.routeId, 28);
- const subLabel = ext ? null : `(${truncate(node.from, 24)})`;
-
const stat = this.#metric && node.exchangesTotal > 0
? { total: node.exchangesTotal, failed: node.exchangesFailed } :
null;
+ // Labels start 30px in (icon zone); keep an 8px margin before the
right border. When a metric badge is
+ // shown in the top-right corner, reserve its measured width on the
first line so the label cannot run
+ // under it. The sub-label sits lower and never collides with the
badge, so it uses the full width.
+ let labelMax = NODE_W - 38;
+ if (stat) {
+ const metricText = stat.failed > 0 ? `${stat.total - stat.failed}
${stat.failed}` : `${stat.total - stat.failed}`;
+ const metricWidth = measureWidth(metricText, 9, fontFamily) ?? 36;
+ labelMax = NODE_W - 8 - metricWidth - 6 - 30;
+ }
+ const fullLabel = ext ? node.from : (node.description ?? node.routeId);
+ const label = fitText(fullLabel, labelMax, 11, false, fontFamily);
+ const subLabel = ext ? null : fitText(node.from, NODE_W - 38, 9, true,
fontFamily);
+
+ // Only attach a hover tooltip when text was actually trimmed (the
label ends with the ellipsis, or the
+ // parenthesised sub-label ends with one). Show the full description
and the full from-uri.
+ const trimmed = label.endsWith('…') || (!!subLabel &&
subLabel.endsWith('…)'));
+ let titleText = trimmed ? (fullLabel ?? '') : null;
+ if (titleText != null && !ext && node.from) {
+ titleText += `\n(${node.from})`;
+ }
+ const title = titleText != null ? `<title>${esc(titleText)}</title>` :
'';
+
const textX = node.x + 30;
const baseY = node.y + NODE_H / 2;
@@ -557,8 +607,9 @@ class CamelTopologyDiagram extends HTMLElement {
return `
<g role="img" aria-label="${esc(node.routeId)}: ${esc(label)}">
+ ${title}
<rect x="${node.x}" y="${node.y}" width="${NODE_W}" height="${NODE_H}"
- rx="6" ry="6" fill="var(--ctd-bg, #ffffff)"/>
+ rx="6" ry="6" fill="var(--ctd-node-bg, #ffffff)"/>
<rect x="${node.x}" y="${node.y}" width="${NODE_W}" height="${NODE_H}"
rx="6" ry="6"
fill="${fill}" fill-opacity="${ext ? '0.08' : '0.15'}"
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
index 3af5a6cb81d1..395506d9d799 100644
---
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
@@ -76,6 +76,19 @@ class WebComponentBundleTest {
}
}
+ @Test
+ void bundledRouteJsMeasuresTextWithTheConfiguredFont() 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("route labels must be measured with the host font
configuration")
+
.contains("getComputedStyle(this).getPropertyValue('--crd-font')")
+ .contains("fitText(full, NODE_W - 38, 11, fontFamily)");
+ }
+ }
+
@Test
void topologyBundledJsExistsInClasspath() {
URL url = getClass().getClassLoader()
@@ -119,6 +132,20 @@ class WebComponentBundleTest {
}
}
+ @Test
+ void
topologyBundledJsMeasuresTextWithTheConfiguredFontAndAllowsBadgeShrinking()
throws IOException {
+ try (InputStream is = getClass().getClassLoader()
+
.getResourceAsStream("META-INF/resources/camel/diagram/camel-topology-diagram.js"))
{
+ assertThat(is).isNotNull();
+ String content = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
+ assertThat(content)
+ .as("topology labels and metric badges must use the
configured font and reserve enough space")
+
.contains("getComputedStyle(this).getPropertyValue('--ctd-font')")
+ .contains("measureWidth(metricText, 9, fontFamily)")
+ .contains("labelMax = NODE_W - 8 - metricWidth - 6 - 30;");
+ }
+ }
+
@Test
void thirdPartyNoticesMentionsLucide() throws IOException {
try (InputStream is = getClass().getClassLoader()
diff --git a/components/camel-diagram/src/test/resources/integration-test.html
b/components/camel-diagram/src/test/resources/integration-test.html
index 9e7c80473640..c6fc302af74e 100644
--- a/components/camel-diagram/src/test/resources/integration-test.html
+++ b/components/camel-diagram/src/test/resources/integration-test.html
@@ -36,7 +36,7 @@
<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) ─── */
+ /* ─── Camel design tokens (mirrored from smoke-test-route.html) ─── */
:root {
--camel-orange: #e97826;
--camel-orange-dark: #cf7428;
@@ -292,7 +292,8 @@
<a href="#eips">EIP Nodes</a>
<a href="#colors">Colors</a>
<a href="#polling">Polling</a>
- <a href="smoke-test.html">Smoke Test</a>
+ <a href="smoke-test-route.html">Smoke · Route</a>
+ <a href="smoke-test-topology.html">Smoke · Topology</a>
</nav>
<span class="badge-pill">Integration Tests</span>
</nav>
diff --git a/components/camel-diagram/src/test/resources/smoke-test.html
b/components/camel-diagram/src/test/resources/smoke-test-route.html
similarity index 90%
rename from components/camel-diagram/src/test/resources/smoke-test.html
rename to components/camel-diagram/src/test/resources/smoke-test-route.html
index 2e9a79cce705..4c2a6691abd0 100644
--- a/components/camel-diagram/src/test/resources/smoke-test.html
+++ b/components/camel-diagram/src/test/resources/smoke-test-route.html
@@ -25,7 +25,7 @@
cd components/camel-diagram/src/
python3 -m http.server 8080
- open http://localhost:8080/test/resources/smoke-test.html
+ open http://localhost:8080/test/resources/smoke-test-route.html
All data is mocked — no running Camel instance needed.
-->
@@ -313,6 +313,34 @@
text-transform: uppercase;
}
+ /* ─── Theme switch (segmented control) ─── */
+ .theme-switch {
+ display: inline-flex;
+ gap: 2px;
+ padding: 2px;
+ background: rgba(255,255,255,.1);
+ border: 1px solid rgba(255,255,255,.18);
+ border-radius: 99px;
+ }
+ .theme-switch button {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: rgba(255,255,255,.75);
+ font: inherit;
+ font-size: .72rem;
+ font-weight: 600;
+ padding: 3px 11px;
+ border-radius: 99px;
+ cursor: pointer;
+ transition: background .15s, color .15s;
+ }
+ .theme-switch button:hover { color: var(--camel-white); }
+ .theme-switch button.active {
+ background: var(--camel-orange);
+ color: var(--camel-white);
+ }
+
/* ─── Hero ─── */
.hero {
background: var(--camel-navy);
@@ -421,6 +449,17 @@
.card-body { padding: 1rem; }
.card-body.dark-bg { background: #0f172a; padding: .75rem; }
+ /* ─── Header-driven theme override ───
+ Drives the diagram sections that are not the self-documenting demo
cards.
+ "os" sets nothing, so the component falls back to prefers-color-scheme.
*/
+ body[data-theme="light"] .themed-diagram {
+ --crd-bg: #ffffff; --crd-fg: #1e293b; --crd-edge: #94a3b8;
+ }
+ body[data-theme="dark"] .themed-diagram {
+ --crd-bg: #0f172a; --crd-fg: #e2e8f0; --crd-edge: #475569;
+ }
+ body[data-theme="dark"] .card-body.themed-diagram { background: #0f172a; }
+
/* ─── 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;
}
@@ -445,7 +484,7 @@
}
</style>
</head>
-<body>
+<body data-theme="os">
<!-- ─── Navigation ─── -->
<nav class="site-nav" aria-label="Site navigation">
@@ -464,6 +503,11 @@
<a href="#kafka">Long URI</a>
<a href="#multi">Multi-Route</a>
</nav>
+ <div class="theme-switch" role="group" aria-label="Diagram theme">
+ <button type="button" data-theme="light"
aria-pressed="false">Light</button>
+ <button type="button" data-theme="dark" aria-pressed="false">Dark</button>
+ <button type="button" data-theme="os" class="active"
aria-pressed="true">OS</button>
+ </div>
<span class="badge-pill">Smoke Test</span>
</nav>
@@ -477,7 +521,7 @@
</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
+ python3 -m http.server 8080 && open
http://localhost:8080/test/resources/smoke-test-route.html
</span>
</div>
</header>
@@ -539,6 +583,19 @@
</div>
</div>
+
+ <div class="card" style="margin-top: 1rem;">
+ <div class="card-header">
+ <h3>Custom font</h3>
+ <p>
+ Override via <code>style="--crd-font: 'Courier New',
monospace"</code> to verify that text measurement
+ matches the font actually used by the SVG.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-route-diagram src="/mock/simple" style="--crd-font: 'Courier
New', monospace;"></camel-route-diagram>
+ </div>
+ </div>
</section>
<!-- ══════════════════════════════════════════════════════
@@ -558,7 +615,7 @@
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">
+ <div class="card-body diagram-scroll themed-diagram">
<camel-route-diagram src="/mock/content-router"></camel-route-diagram>
</div>
</div>
@@ -583,7 +640,7 @@
Mirrors the
<code>testChoiceInsideDoTryNoSpuriousMergeConnection</code> layout-engine test.
</p>
</div>
- <div class="card-body diagram-scroll">
+ <div class="card-body diagram-scroll themed-diagram">
<camel-route-diagram src="/mock/error-handling"></camel-route-diagram>
</div>
</div>
@@ -609,7 +666,7 @@
post-multicast <code>log</code> reconnects from the
<code>multicast</code> merge point.
</p>
</div>
- <div class="card-body diagram-scroll">
+ <div class="card-body diagram-scroll themed-diagram">
<camel-route-diagram src="/mock/multicast"></camel-route-diagram>
</div>
</div>
@@ -623,7 +680,7 @@
from the <code>circuitBreaker</code> merge point.
</p>
</div>
- <div class="card-body diagram-scroll">
+ <div class="card-body diagram-scroll themed-diagram">
<camel-route-diagram
src="/mock/circuit-breaker"></camel-route-diagram>
</div>
</div>
@@ -649,7 +706,7 @@
exchanges — spot the error rate at a glance.
</p>
</div>
- <div class="card-body diagram-scroll">
+ <div class="card-body diagram-scroll themed-diagram">
<camel-route-diagram src="/mock/metrics"></camel-route-diagram>
</div>
</div>
@@ -673,7 +730,7 @@
Mirrors <code>testTextWrappingLongLabel</code>.
</p>
</div>
- <div class="card-body diagram-scroll">
+ <div class="card-body diagram-scroll themed-diagram">
<camel-route-diagram src="/mock/kafka-long-uri"></camel-route-diagram>
</div>
</div>
@@ -697,7 +754,7 @@
Nodes in the generator route use the <code>description</code> field
for human-readable labels.
</p>
</div>
- <div class="card-body diagram-scroll">
+ <div class="card-body diagram-scroll themed-diagram">
<camel-route-diagram src="/mock/multi-route"></camel-route-diagram>
</div>
</div>
@@ -716,5 +773,24 @@
</p>
</footer>
+<script>
+ /* eslint-disable */
+ /* Header theme switch: light/dark force --crd-* on the body; "os" clears
them
+ * so the component falls back to prefers-color-scheme. */
+ (function () {
+ const sw = document.querySelector('.theme-switch');
+ sw.addEventListener('click', function (e) {
+ const btn = e.target.closest('button[data-theme]');
+ if (!btn) return;
+ document.body.dataset.theme = btn.dataset.theme;
+ sw.querySelectorAll('button').forEach(function (b) {
+ const on = b === btn;
+ b.classList.toggle('active', on);
+ b.setAttribute('aria-pressed', String(on));
+ });
+ });
+ })();
+</script>
+
</body>
</html>
diff --git
a/components/camel-diagram/src/test/resources/smoke-test-topology.html
b/components/camel-diagram/src/test/resources/smoke-test-topology.html
new file mode 100644
index 000000000000..03d4a0679f7f
--- /dev/null
+++ b/components/camel-diagram/src/test/resources/smoke-test-topology.html
@@ -0,0 +1,669 @@
+<!--
+
+ 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-topology-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-topology.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-topology-diagram · Smoke Test</title>
+ <script type="module">
+ import
'../../main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js';
+ </script>
+ <script>
+ /* eslint-disable */
+
+ /* ──────────────────────────────────────────────────────────────────────
+ * Mock data registry — each key is a URL path used as src="…" below.
+ *
+ * Topology payload shape (what the dev console serves at /route-topology):
+ * nodes: [{ routeId, description?, from, fromScheme?, nodeType,
exchangesTotal?, exchangesFailed? }]
+ * edges: [{ fromRouteId, toRouteId, endpoint?, connectionType? }]
+ * externalEndpoints: [{ id, routeId, uri, scheme, direction:'in'|'out',
exchangesTotal?, exchangesFailed? }]
+ *
+ * nodeType drives the node color: 'trigger' → orange, 'external*' → gray,
else → purple.
+ * ──────────────────────────────────────────────────────────────────────
*/
+ const MOCK = {
+
+ /* 1. Minimal linear pipeline — used in the three theme-comparison cards.
+ * Short labels keep the focus on color, not truncation. */
+ '/mock/topology/simple': {
+ nodes: [
+ { routeId: 'generator', from: 'timer:tick', fromScheme:
'timer', nodeType: 'trigger' },
+ { routeId: 'processor', from: 'direct:process', fromScheme:
'direct', nodeType: 'route' },
+ { routeId: 'validator', from: 'direct:validate', fromScheme:
'direct', nodeType: 'route' },
+ ],
+ edges: [
+ { fromRouteId: 'generator', toRouteId: 'processor', endpoint:
'direct:process' },
+ { fromRouteId: 'processor', toRouteId: 'validator', endpoint:
'direct:validate' },
+ ],
+ },
+
+ /* 2. Route pipeline with a disconnected route.
+ * Mirrors TopologyDumperTest: generator → processor → validator,
plus an isolated route. */
+ '/mock/topology/pipeline': {
+ nodes: [
+ { routeId: 'generator', from: 'timer:tick?period=5000', fromScheme:
'timer', nodeType: 'trigger' },
+ { routeId: 'processor', from: 'direct:process', fromScheme:
'direct', nodeType: 'route' },
+ { routeId: 'validator', from: 'direct:validate', fromScheme:
'direct', nodeType: 'route' },
+ { routeId: 'isolated', from: 'direct:isolated', fromScheme:
'direct', nodeType: 'route' },
+ ],
+ edges: [
+ { fromRouteId: 'generator', toRouteId: 'processor', endpoint:
'direct:process' },
+ { fromRouteId: 'processor', toRouteId: 'validator', endpoint:
'direct:validate' },
+ ],
+ },
+
+ /* 3. External endpoints — in/out boundary nodes (dashed, gray) added
when external="true". */
+ '/mock/topology/external': {
+ nodes: [
+ { routeId: 'orders-api', from: 'platform-http:/api/orders',
fromScheme: 'platform-http',
+ description: 'Receives orders from external systems', nodeType:
'route' },
+ { routeId: 'fulfilment', from: 'direct:fulfil',
fromScheme: 'direct',
+ description: 'Fans out orders to fulfilment services', nodeType:
'route' },
+ ],
+ edges: [
+ { fromRouteId: 'orders-api', toRouteId: 'fulfilment', endpoint:
'direct:fulfil' },
+ ],
+ externalEndpoints: [
+ { id: 'ext-http-in', routeId: 'orders-api', uri:
'platform-http:/api/orders', scheme: 'platform-http', direction: 'in' },
+ { id: 'ext-kafka-out', routeId: 'fulfilment', uri:
'kafka:shipped-orders', scheme: 'kafka', direction: 'out' },
+ ],
+ },
+
+ /* 4. Interlinked shared endpoint — two producers publishing to the same
broker topic.
+ * With external="true" interlink="true" the shared kafka:shared
endpoint collapses to one node. */
+ '/mock/topology/interlink': {
+ nodes: [
+ { routeId: 'producer-a', from: 'timer:a', fromScheme: 'timer',
nodeType: 'trigger' },
+ { routeId: 'producer-b', from: 'timer:b', fromScheme: 'timer',
nodeType: 'trigger' },
+ { routeId: 'consumer', from: 'kafka:shared', fromScheme: 'kafka',
nodeType: 'route' },
+ ],
+ edges: [
+ { fromRouteId: 'producer-a', toRouteId: 'consumer', endpoint:
'kafka:shared', connectionType: 'external' },
+ { fromRouteId: 'producer-b', toRouteId: 'consumer', endpoint:
'kafka:shared', connectionType: 'external' },
+ ],
+ },
+
+ /* 5. Live metrics — per-route exchange counters rendered top-right of
each box (metric="true").
+ * Long descriptions exercise the ellipsis truncation and the hover
tooltip. */
+ '/mock/topology/metrics': {
+ nodes: [
+ { routeId: 'orders-api', from: 'platform-http:/api/orders',
fromScheme: 'platform-http',
+ description: 'Receives orders from external systems', nodeType:
'route',
+ exchangesTotal: 1234, exchangesFailed: 7 },
+ { routeId: 'order-generator', from: 'timer:orders?period=5s',
fromScheme: 'timer',
+ description: 'Generate a new order every 5 seconds', nodeType:
'trigger',
+ exchangesTotal: 96, exchangesFailed: 0 },
+ { routeId: 'processor', from: 'direct:process-order',
fromScheme: 'direct',
+ description: 'Validates and publishes orders to the broker',
nodeType: 'route',
+ exchangesTotal: 1227, exchangesFailed: 7 },
+ { routeId: 'fulfilment', from: 'kafka:orders',
fromScheme: 'kafka',
+ description: 'Fans out orders to fulfilment services', nodeType:
'route',
+ exchangesTotal: 1220, exchangesFailed: 0 },
+ ],
+ edges: [
+ { fromRouteId: 'orders-api', toRouteId: 'processor', endpoint:
'direct:process-order' },
+ { fromRouteId: 'order-generator', toRouteId: 'processor', endpoint:
'direct:process-order' },
+ { fromRouteId: 'processor', toRouteId: 'fulfilment', endpoint:
'kafka:orders' },
+ ],
+ },
+
+ /* 6. Badge stress — large counters reserve width for the metric text
and leave only a sliver for the label. */
+ '/mock/topology/metrics-heavy': {
+ nodes: [
+ { routeId: 'ingress', from: 'platform-http:/api/orders', fromScheme:
'platform-http',
+ description: 'Accepts incoming orders from external systems and
validates them', nodeType: 'route',
+ exchangesTotal: 1234567890, exchangesFailed: 987654321 },
+ { routeId: 'fanout', from: 'direct:fanout', fromScheme: 'direct',
+ description: 'Distributes the order to fulfillment, notification,
and archive routes', nodeType: 'route',
+ exchangesTotal: 987654321, exchangesFailed: 12345678 },
+ ],
+ edges: [
+ { fromRouteId: 'ingress', toRouteId: 'fanout', endpoint:
'direct:fanout' },
+ ],
+ },
+ };
+
+ 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;
+ }
+
+ /* ─── Theme switch (segmented control) ─── */
+ .theme-switch {
+ display: inline-flex;
+ gap: 2px;
+ padding: 2px;
+ background: rgba(255,255,255,.1);
+ border: 1px solid rgba(255,255,255,.18);
+ border-radius: 99px;
+ }
+ .theme-switch button {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: rgba(255,255,255,.75);
+ font: inherit;
+ font-size: .72rem;
+ font-weight: 600;
+ padding: 3px 11px;
+ border-radius: 99px;
+ cursor: pointer;
+ transition: background .15s, color .15s;
+ }
+ .theme-switch button:hover { color: var(--camel-white); }
+ .theme-switch button.active {
+ background: var(--camel-orange);
+ color: var(--camel-white);
+ }
+
+ /* ─── 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-pipeline { background: #e3f2fd; color: #0d47a1; }
+ .tag-external { background: #eceff1; color: #37474f; }
+ .tag-interlink{ background: #f3e5f5; color: #4a148c; }
+ .tag-metrics { 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 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; }
+
+ /* ─── Header-driven theme override ───
+ Drives the diagram sections that are not the self-documenting demo
cards.
+ "os" sets nothing, so the component falls back to prefers-color-scheme.
*/
+ body[data-theme="light"] .themed-diagram {
+ --ctd-bg: #ffffff; --ctd-fg: #1e293b; --ctd-edge: #94a3b8;
+ }
+ body[data-theme="dark"] .themed-diagram {
+ --ctd-bg: #0f172a; --ctd-fg: #e2e8f0; --ctd-edge: #475569;
+ }
+ body[data-theme="dark"] .card-body.themed-diagram { background: #0f172a; }
+
+ /* ─── 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 data-theme="os">
+
+<!-- ─── 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-topology-diagram</div>
+ </div>
+ <div class="nav-spacer"></div>
+ <nav class="nav-links" aria-label="Jump to section">
+ <a href="#themes">Themes</a>
+ <a href="#pipeline">Pipeline</a>
+ <a href="#external">External</a>
+ <a href="#interlink">Interlink</a>
+ <a href="#metrics">Metrics</a>
+ </nav>
+ <div class="theme-switch" role="group" aria-label="Diagram theme">
+ <button type="button" data-theme="light"
aria-pressed="false">Light</button>
+ <button type="button" data-theme="dark" aria-pressed="false">Dark</button>
+ <button type="button" data-theme="os" class="active"
aria-pressed="true">OS</button>
+ </div>
+ <span class="badge-pill">Smoke Test</span>
+</nav>
+
+<!-- ─── Hero ─── -->
+<header class="hero">
+ <div class="hero-inner">
+ <h1><code><camel-topology-diagram></code></h1>
+ <p>
+ A local verification page for the topology-diagram web component — the
inter-route view served by the
+ dev console at <code>?mode=topology</code>. Each section exercises a
different 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-topology.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>--ctd-*</code>
variables
+ are set — the component picks light or dark based on your OS
setting.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-topology-diagram src="/mock/topology/simple" external="false"
metric="false"></camel-topology-diagram>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-header">
+ <h3>Light (forced)</h3>
+ <p>
+ Override via inline <code>style="--ctd-bg:#fff; --ctd-fg:#1e293b;
--ctd-edge:#94a3b8"</code>
+ to pin the light palette regardless of OS setting.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-topology-diagram src="/mock/topology/simple" external="false"
metric="false"
+ style="--ctd-bg:#ffffff; --ctd-fg:#1e293b; --ctd-edge:#94a3b8;">
+ </camel-topology-diagram>
+ </div>
+ </div>
+
+ <div class="card">
+ <div class="card-header">
+ <h3>Dark (forced)</h3>
+ <p>
+ Override via <code>style="--ctd-bg:#0f172a; --ctd-fg:#e2e8f0;
--ctd-edge:#475569"</code>
+ to pin the dark palette regardless of OS setting. The node-box
fill tracks
+ <code>--ctd-bg</code> so labels never land light-on-light.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll dark-bg">
+ <camel-topology-diagram src="/mock/topology/simple" external="false"
metric="false"
+ style="--ctd-bg:#0f172a; --ctd-fg:#e2e8f0; --ctd-edge:#475569;">
+ </camel-topology-diagram>
+ </div>
+ </div>
+
+ </div>
+
+ <div class="card" style="margin-top: 1rem;">
+ <div class="card-header">
+ <h3>Custom font</h3>
+ <p>
+ Override via <code>style="--ctd-font: 'Courier New',
monospace"</code> to verify that text measurement
+ matches the font actually used by the SVG.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll">
+ <camel-topology-diagram src="/mock/topology/simple" external="false"
metric="false"
+ style="--ctd-font: 'Courier New', monospace;">
+ </camel-topology-diagram>
+ </div>
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 2 — Route pipeline
+ ══════════════════════════════════════════════════════ -->
+ <section id="pipeline" class="section">
+ <div class="section-header">
+ <h2>Route Pipeline</h2>
+ <span class="section-tag tag-pipeline">Multiple Routes</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>Generator → Processor → Validator, plus a disconnected route</h3>
+ <p>
+ A timer <strong>generator</strong> (orange <code>trigger</code>
node) hands off to
+ <strong>processor</strong> via <code>direct:process</code>, which
forwards to
+ <strong>validator</strong> via <code>direct:validate</code>. The
<strong>isolated</strong> route
+ has no inbound or outbound links, verifying that disconnected routes
still render standalone.
+ Mirrors the topology in <code>TopologyDumperTest</code>.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll themed-diagram">
+ <camel-topology-diagram src="/mock/topology/pipeline" external="false"
metric="false"></camel-topology-diagram>
+ </div>
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 3 — External endpoints
+ ══════════════════════════════════════════════════════ -->
+ <section id="external" class="section">
+ <div class="section-header">
+ <h2>External Endpoints</h2>
+ <span class="section-tag tag-external">external="true"</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>In/out boundary nodes around the route graph</h3>
+ <p>
+ With <code>external="true"</code> the component reads
<code>externalEndpoints</code> and adds
+ dashed gray boundary nodes: an inbound <code>platform-http</code>
endpoint feeding
+ <strong>orders-api</strong>, and an outbound
<code>kafka:shipped-orders</code> endpoint that
+ <strong>fulfilment</strong> publishes to. Verifies the three-band
layout (external-in / routes /
+ external-out) and the dashed external edges.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll themed-diagram">
+ <camel-topology-diagram src="/mock/topology/external" external="true"
metric="false"></camel-topology-diagram>
+ </div>
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 4 — Interlinked shared endpoint
+ ══════════════════════════════════════════════════════ -->
+ <section id="interlink" class="section">
+ <div class="section-header">
+ <h2>Interlinked Shared Endpoint</h2>
+ <span class="section-tag tag-interlink">interlink="true"</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>Two producers collapsing onto one shared broker node</h3>
+ <p>
+ <strong>producer-a</strong> and <strong>producer-b</strong> both
publish to
+ <code>kafka:shared</code>. With <code>external="true"
interlink="true"</code> the shared endpoint
+ is hoisted into a single intermediate node both producers connect
through, rather than drawing two
+ independent edges. Verifies <code>expandExternalEdges</code>
shared-endpoint collapsing.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll themed-diagram">
+ <camel-topology-diagram src="/mock/topology/interlink" external="true"
interlink="true" metric="false"></camel-topology-diagram>
+ </div>
+ </div>
+ </section>
+
+ <!-- ══════════════════════════════════════════════════════
+ SECTION 5 — Live metrics
+ ══════════════════════════════════════════════════════ -->
+ <section id="metrics" class="section">
+ <div class="section-header">
+ <h2>Live Metrics</h2>
+ <span class="section-tag tag-metrics">metric="true"</span>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>Per-route exchange counters with long, truncated labels</h3>
+ <p>
+ With <code>metric="true"</code> each route box shows
+ <em>✓ successes / ✗ failures</em> in the
top-right corner
+ (green successes, red failures). The descriptions are deliberately
long: they are trimmed to a
+ clean ellipsis that reserves room for the metric badge, and hovering
a trimmed box reveals the
+ full text and underlying URI via a native tooltip. Reproduces the
route-topology blog screenshot.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll themed-diagram">
+ <camel-topology-diagram src="/mock/topology/metrics" external="false"
metric="true"></camel-topology-diagram>
+ </div>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h3>Large counters reserve room for the badge</h3>
+ <p>
+ This case keeps the default theme but pushes the exchange counters
into multiple digits, so the
+ first-line label must shrink enough to stay out from under the
metric badge.
+ </p>
+ </div>
+ <div class="card-body diagram-scroll themed-diagram">
+ <camel-topology-diagram src="/mock/topology/metrics-heavy"
external="false" metric="true"></camel-topology-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-topology-diagram</code> web component
+ ·
+ Local smoke test — not a production page.
+ </p>
+</footer>
+
+<script>
+ /* eslint-disable */
+ /* Header theme switch: light/dark force --ctd-* on the body; "os" clears
them
+ * so the component falls back to prefers-color-scheme. */
+ (function () {
+ const sw = document.querySelector('.theme-switch');
+ sw.addEventListener('click', function (e) {
+ const btn = e.target.closest('button[data-theme]');
+ if (!btn) return;
+ document.body.dataset.theme = btn.dataset.theme;
+ sw.querySelectorAll('button').forEach(function (b) {
+ const on = b === btn;
+ b.classList.toggle('active', on);
+ b.setAttribute('aria-pressed', String(on));
+ });
+ });
+ })();
+</script>
+
+</body>
+</html>