サーバー Web アプリのトポロジー描画スタック — @shumoku/renderer を
中核に、その上に複数のオーバーレイ(weathermap / node-status /
highlight / tooltip / camera)が乗る構造 — を アーキテクチャ・
構造・相関・フロー の4軸で説明する。
オーバーレイは大きく 2 種類 に分かれる:
- 構造化 overlay: renderer の
linkOverlay/nodeOverlay等の snippet で、各 SVG エンティティの中に直接 render される - サイドカー overlay: renderer から
svgElementだけを受け取って、 自前で DOM 操作(class 付与、event listener、tooltip 配置等)する
実装の中核:
libs/@shumoku/renderer/— 描画中核(Svelte / Web Component / camera)apps/server/web/src/lib/components/topology/— サーバ専用 Svelte オーバーレイ群apps/server/web/src/lib/weathermap/— weathermap 用の色 / duration / ジオメトリ helper
1. 概要
トポロジー描画は数十〜数百のリンクを同時に表示しつつ、ページは パン/ズーム、ホバー、WebSocket メトリクス更新も並行して処理する。 3 つの設計原則でこれを軽くしている。
- renderer がドメイン構造を所有する。 subgraph / link / node / port の DOM 順は renderer が決め、外側からは触らない。z-order は この宣言順だけで決まる。
- オーバーレイは 2 つの形に揃える。
- 各エンティティの中に何かを描き足したい → renderer が公開する
linkOverlay/nodeOverlay/subgraphOverlay/portOverlaysnippet を使う - エンティティ横断 / DOM event / グローバル装飾 → svgElement を 受け取るサイドカーとして書く
- 各エンティティの中に何かを描き足したい → renderer が公開する
- 毎フレームのアニメは CSS が動かす。 ブラウザが
stroke-dashoffset/ pulse / transition の keyframe ループを 所有し、JS は値の更新だけを担う。
2. アーキテクチャ
2.1 消費モード
@shumoku/renderer は 2 つのモードで使える。サイドカー overlay は
どちらでも動くが、構造化 overlay (snippet 方式) は Svelte 限定。
| モード | 形 | 使用場所 | DOM | snippet |
|---|---|---|---|---|
| Svelte component | <ShumokuRenderer> | apps/editor, apps/server/web | light DOM | ✓ |
| Web Component | <shumoku-renderer> カスタム要素 | 任意の HTML/他フレームワーク | Shadow DOM(mode: 'open') | ✗(代替案は 7 節) |
WC 版は libs/@shumoku/renderer/src/wc.svelte.ts で vite が
dist/wc/wc.js にバンドルし、customElements.define('shumoku-renderer', ...)
でタグ登録する。
2.2 パッケージ境界
libs/@shumoku/renderer/ ← 描画の中核(外部パッケージ)
├── components/
│ ├── ShumokuRenderer.svelte ← 外向き Svelte component
│ └── svg/
│ ├── SvgCanvas.svelte
│ ├── SvgEdge.svelte ← path.link + linkOverlay snippet host
│ ├── SvgNode.svelte ← g.node + nodeOverlay snippet host
│ └── SvgPort.svelte ← port box + portOverlay snippet host
├── lib/
│ ├── camera.ts ← attachCamera 関数(d3-zoom + wheel-gestures)
│ └── overlays.ts ← snippet の型定義(LinkOverlayContext 等)
├── wc.svelte.ts ← Web Component ラッパー
└── index.ts ← 公開 API
apps/server/web/src/lib/
├── components/topology/ ← server-web 専用 Svelte
│ ├── TopologyViewer.svelte ← renderer を mount + sidecar を呼ぶ
│ ├── WeathermapLinkOverlay.svelte (構造化, linkOverlay snippet)
│ ├── NodeStatusOverlay.svelte (サイドカー)
│ ├── HighlightOverlay.svelte (サイドカー、imperative API もあり)
│ └── TooltipOverlay.svelte (サイドカー、DOM event)
└── weathermap/
└── index.ts ← weathermap helper(色 / duration / 幾何)
依存の向きは常に上向き(server-web は renderer に依存するが、 renderer は server-web を知らない)。renderer 単体で editor / docs / CLI でも動く。
2.3 オーバーレイの2分類
構造化 overlay(snippet)
renderer が描画順を所有したまま、各ドメイン要素の 中 に Svelte snippet で拡張点を公開する。snippet は domain object と context を 受け取り、その position に直接 render される。
| snippet | 挿入位置 | 主な用途 | 実装例 |
|---|---|---|---|
subgraphOverlay(subgraph, ctx) | g.subgraph 背景 rect 後、label 前 | group 背景装飾 | (未使用) |
linkOverlay(edge, ctx) | g.link-group の base link 後、hit path / label 前 | weathermap lane、link 装飾 | WeathermapLinkOverlay |
nodeOverlay(node, ctx) | g.node-bg 後、g.node-fg 前 | node 内バッジ、状態装飾 | (未使用) |
portOverlay(port, ctx) | port box 後、label 前 | port 内状態、利用率表示 | (未使用) |
context には domain object 参照 と DOM 参照 の両方が入る:
// libs/@shumoku/renderer/src/lib/overlays.ts
export interface LinkOverlayContext {
selected: boolean
groupElement: SVGGElement | null // ← 親 g.link-group
pathElement: SVGPathElement | null // ← base path.link
pathD: string
width: number // ← getLinkWidth(link)
fromPort: ResolvedPort | null
toPort: ResolvedPort | null
...
}
これにより overlay は closest('g.link-group') のような class 名逆引き
を一切しない。renderer が DOM 構造を refactor しても snippet 契約だけ
保てば壊れない。
サイドカー overlay
renderer 本体を直接 import せず、svgElement: SVGSVGElement だけ
受け取って、その中で DOM 操作する。複数エンティティを横断する処理 /
DOM event ハンドリング / グローバル装飾はこちら。
flowchart LR
HOST["ホスト<br/>Svelte ページ or 任意の HTML"]
RND["@shumoku/renderer"]
SVG["SVG element<br/>(svgElement)"]
subgraph SIDECARS["Sidecar overlays"]
direction TB
NSO["NodeStatusOverlay"]
HLO["HighlightOverlay"]
TTO["TooltipOverlay"]
CAM["attachCamera"]
end
HOST -->|graph prop| RND
RND -->|svgElement 公開| HOST
HOST -->|svgElement 手渡し| SIDECARS
SIDECARS -->|class 付与<br/>event listener<br/>tooltip 配置| SVG
サイドカーの契約:
- 入力は
svgElementのみ。renderer 内部に依存しない - DOM 操作は渡された svg 配下に閉じる(
document.querySelectorは使わない) destroy()/detach()で自分が付けたものを全部クリーンアップ- renderer の DOM 属性は読むだけ、書き換えない
- CSS 注入は
svelte:headまたは scoped style(現状 light DOM 前提、詳細は 7 節)
サイドカーは現状 renderer の class 名 / data-attribute に依存する
(e.g. g.node[data-id]、g.link-group[data-link-id])。これらは
renderer の “public DOM contract” として安定扱い。
2.4 描画型モデルと設計思想
トポロジーの概念構造はグラフであり、SVG DOM は木構造である。特に
Port は「Node の所有物」である一方、「Link の端点」でもあるため、
DOM の親子だけで概念を表そうとすると破綻しやすい。
このため、描画型モデルは 所有(ownership) と 参照(reference) を分けている。
Node owns Port
Link references endpoint Ports
ResolvedEdge owns resolved endpoint references
Renderer owns SVG order
Overlay receives typed context
Core model
core の生モデルでは、Node / Subgraph / Link がユーザー入力に近い
ドメインオブジェクト。Port は Node に属するインターフェースであり、
Link は from / to endpoint で node/port を参照する。
Node
└─ Port definition
Link
├─ from: { node, port? }
└─ to: { node, port? }
ここで Link が Port の実体コピーを持たないのは、ラベル変更、ポート サイズ、位置、削除時の正本を Node 側に一本化するため。Port を Node と Link の両方が実体所有すると、同期問題が発生する。
Resolved layout model
layout 後は、描画に必要な座標と関係を Resolved* 型に落とす。
| 型 | 役割 |
|---|---|
Node | position 済みの node 本体 |
Subgraph | bounds 済みの group |
ResolvedPort | Node 所有の port を絶対座標に解決したもの |
ResolvedEdge | Link の routing 結果 + endpoint port 参照 |
ResolvedEdge は fromPortId / toPortId だけでなく、
fromPort / toPort も持つ。
interface ResolvedEdge {
id: string
fromPortId: string | null
toPortId: string | null
fromPort: ResolvedPort | null
toPort: ResolvedPort | null
points: Position[]
width: number
link: Link
}
これは「Port の所有者は Node」という原則を保ったまま、Link 側の描画
や overlay が endpoint port を型付きで直接扱えるようにするため。
renderer 側で ports.get(edge.fromPortId) のように逆引きしない。
Renderer model
renderer は ResolvedLayout を SVG DOM に落とす責務を持つ。描画順は
renderer が所有し、overlay はこの順序を変更しない。
g.viewport
g.subgraph
g.link-group
g.node
g.port
この順序は z-order そのものでもある。SVG は後に出た要素ほど上に描かれる ため、z-order を CSS や overlay 側 DOM 操作で調整しない。
Overlay context model
構造化 overlay は、DOM を query して必要情報を探すのではなく、renderer から typed context として受け取る。
linkOverlay(edge, ctx)
edge には routing 済みの path と endpoint port 参照があり、ctx には
renderer が生成した DOM 参照が入る。
interface LinkOverlayContext {
groupElement: SVGGElement | null
pathElement: SVGPathElement | null
pathD: string
width: number
fromPort: ResolvedPort | null
toPort: ResolvedPort | null
}
この分担により、overlay は .link-group や path.link という class 名を
知る必要がない。class 名に依存するのは、複数要素を横断するサイドカー
overlay に限定する。
2.5 Renderer boundary
renderer は リッチなブラウザ機能を内蔵する場所ではない。renderer の 責務は、core の topology domain を安定した SVG 構造へ落とし、外側の ブラウザ層が安全に拡張できる場所と型を提供すること。
言い換えると、renderer は「何を意味するか」ではなく 「どこに、どの 構造で描けるか」 を所有する。
renderer = stable structure provider
host layer = semantic behavior provider
overlay = typed bridge between them
renderer が所有するもの
| 責務 | 理由 |
|---|---|
NetworkGraph / ResolvedLayout を SVG DOM に落とす | renderer の本分 |
subgraph → link → node → port の z-order | SVG の描画順は DOM 順で決まるため |
| 各 entity 内の基本 DOM 構造 | overlay が安全に乗る土台 |
| hit target / selection の基本イベント | editor / server-web / docs で共通 |
linkOverlay 等の typed extension point | 外側の機能を renderer に混ぜないため |
svgElement の公開 | camera / tooltip 等の sidecar を可能にするため |
汎用的な mode / theme | topology renderer として横断的に意味があるため |
renderer が所有しないもの
| 責務 | 所有者 |
|---|---|
| live metrics / WebSocket / polling | host app / store |
| traffic flow / weathermap の意味論 | server-web overlay |
| node status の解釈(up/down/degraded 等) | server-web overlay |
| highlight のルール / spotlight / dashboard event | host app / overlay |
| tooltip の内容生成 | host app / overlay |
| utilization color map / alert threshold | domain-specific layer |
| dashboard widget の状態 / config | server-web |
| API fetch / auth / tenant context | server-web |
これらを renderer に入れ始めると、renderer が @shumoku/core の汎用描画
ではなく server-web dashboard 専用 UI になってしまう。たとえば
metrics / nodeStatus / showTrafficFlow / tooltipBuilder のような
props は renderer に追加しない。
判断基準
新しい機能を renderer に入れるか overlay / host layer に置くか迷ったら、 以下で判断する。
| 問い | yes なら |
|---|---|
NetworkGraph / ResolvedLayout だけで説明できるか? | renderer 候補 |
| editor / docs / WC / server-web のどれでも同じ意味があるか? | renderer 候補 |
| SVG 構造、z-order、hit area、汎用 event に関するものか? | renderer 候補 |
| server-web の store / API / live metrics を知る必要があるか? | host / overlay |
| 状態の意味解釈(status, utilization, alert 等)を含むか? | host / overlay |
| 見た目の効果は汎用だが、発火条件が業務固有か? | renderer は拡張点だけ提供 |
基本方針は、renderer には 拡張点と型 を足し、意味論は外側に置くこと。
今回の weathermap も renderer に traffic 機能を入れたのではなく、
linkOverlay(edge, ctx) という link 内拡張点を公開し、server-web 側が
その上に traffic 表現を載せている。
3. 構造
3.1 Svelte コンポーネントツリー
flowchart TD
PAGE["ページ<br/>graph / metrics ストア保持"]
TV["TopologyViewer.svelte<br/>layout キャッシュ / attachCamera / ViewerContext 出力"]
SR["ShumokuRenderer.svelte<br/>(from @shumoku/renderer)"]
SC["SvgCanvas.svelte"]
SE["SvgEdge.svelte<br/>+ linkOverlay snippet host"]
SN["SvgNode.svelte<br/>+ nodeOverlay snippet host"]
SP["SvgPort.svelte<br/>+ portOverlay snippet host"]
WML["WeathermapLinkOverlay<br/>(構造化)"]
NSO["NodeStatusOverlay<br/>(sidecar)"]
HLO["HighlightOverlay<br/>(sidecar)"]
TTO["TooltipOverlay<br/>(sidecar)"]
PAGE -->|graph / metrics| TV
TV -->|layout / theme<br/>bind:svgElement| SR
SR --> SC
SC --> SE
SC --> SN
SC --> SP
TV -.linkOverlay snippet.-> WML
TV -.svgElement.-> NSO
TV -.svgElement.-> HLO
TV -.svgElement.-> TTO
classDef renderer fill:#eef6ff,stroke:#6aa0ff
classDef structured fill:#fff5e6,stroke:#f0a040
classDef sidecar fill:#e6ffe6,stroke:#50b050
class SR,SC,SE,SN,SP renderer
class WML structured
class NSO,HLO,TTO sidecar
青 = renderer 内部 / 橙 = 構造化 overlay / 緑 = サイドカー overlay
3.2 描画後の SVG DOM ツリー
flowchart TD
SVG["svg"]
VP["g.viewport<br/>(d3-zoom が transform)"]
CBG["g.canvas-bg"]
SGS["g.subgraph"]
LG["g.link-group<br/>active 時に .wm-active"]
LK["path.link"]
WMI["path.wm-overlay direction=in<br/>(WeathermapLinkOverlay)"]
WMO["path.wm-overlay direction=out<br/>(WeathermapLinkOverlay)"]
LH["path.link-hit"]
N["g.node<br/>+ .status-up/down/...<br/>+ .monitoring-healthy/failing/...<br/>+ .node-highlighted/dimmed"]
NB["g.node-bg rect"]
NF["g.node-fg<br/>(icon, label)"]
MB["g.shumoku-monitoring-badge<br/>(injected by NodeStatusOverlay)"]
P["g.port<br/>(port box + label)"]
SVG --> VP
VP --> CBG
VP --> SGS
VP --> LG
LG --> LK
LG --> WMI
LG --> WMO
LG --> LH
VP --> N
N --> NB
N --> NF
N --> MB
VP --> P
z-order の不変条件: renderer が .viewport 内の大枠順序
subgraphs → edges → nodes → ports を所有する。各エンティティの内部順序
(base → overlay snippet → hit area / label)も renderer が固定する。
overlay 側で DOM 挿入位置を弄る必要は無い。
4. 相関
4.1 Svelte ↔ TS 実装の wrap 関係
オーバーレイの実装パターンは 3 種類:
| パターン | 例 | DOM 操作 | 状態管理 |
|---|---|---|---|
| 構造化 overlay(.svelte 単独) | WeathermapLinkOverlay | template の <path> を Svelte が render | $derived / $effect |
| サイドカー(.svelte 単独) | NodeStatusOverlay / HighlightOverlay / TooltipOverlay | svgElement.querySelectorAll(...) + classList 操作 | $effect |
| サイドカー(関数) | attachCamera | svgElement に listener 追加 + d3-zoom | closure |
flowchart LR
subgraph SVELTE["Svelte コンポーネント層"]
direction TB
WML["WeathermapLinkOverlay<br/>構造化 .svelte"]
NSO["NodeStatusOverlay<br/>sidecar .svelte"]
HLO["HighlightOverlay<br/>sidecar .svelte"]
TTO["TooltipOverlay<br/>sidecar .svelte"]
end
subgraph HELPERS["TS 実装"]
direction TB
WMG["weathermap helpers<br/>lib/weathermap/index.ts<br/>(色 / duration / 幾何)"]
CAM["attachCamera 関数<br/>renderer/lib/camera.ts"]
end
WML -->|getUtilizationColor<br/>bpsToDurationMs<br/>utilizationToDurationMs| WMG
WML -->|bezierOffsetPath| BEZ["@shumoku/core<br/>layout/bezier-path.ts"]
TTO -. addEventListener .-> SVG[svgElement]
HLO -. classList + querySelector .-> SVG
NSO -. classList + querySelector .-> SVG
TVCAM[TopologyViewer<br/>camera 部分] -->|attach / detach| CAM
classDef svelte fill:#fff5e6,stroke:#f0a040
classDef ts fill:#e6ffe6,stroke:#50b050
class WML,NSO,HLO,TTO svelte
class WMG,CAM ts
4.2 ライフサイクルの手綱
各オーバーレイは Svelte 5 runes($props / $effect / $derived)
の標準パターン:
// 構造化 overlay の骨格 (WeathermapLinkOverlay.svelte)
let { context, metrics, enabled, animation }: Props = $props()
const baseColor = $derived(/* ... */)
$effect(() => {
const group = context.groupElement
if (!group || !active) {
group?.classList.remove('wm-active')
return
}
group.classList.add('wm-active')
group.style.setProperty('--wm-base-color', baseColor)
return () => {
group.classList.remove('wm-active')
group.style.removeProperty('--wm-base-color')
}
})
// サイドカー overlay の骨格 (NodeStatusOverlay.svelte)
let { svgElement, status, enabled }: Props = $props()
$effect(() => {
if (!svgElement || !enabled) return
const svg = svgElement
for (const [id, meta] of Object.entries(status ?? {})) {
const node = svg.querySelector(`g.node[data-id="${CSS.escape(id)}"]`)
if (!node) continue
// Device 状態 = 枠 (stroke)
node.classList.add(`status-${meta.status}`)
// Monitoring 健全性 = 角バッジ (separate SVG element)
if (meta.monitoring) {
node.classList.add(`monitoring-${meta.monitoring}`)
ensureBadge(node) // <g class="shumoku-monitoring-badge"><circle/></g>
}
}
return () => {
/* clear classes + remove badges on cleanup */
}
})
共通の不変条件:
- svgElement / context が変わったら自動的に reset(
$effect依存追跡) - 小さい props 変更だけでは再構築しない(必要な計算は
$derivedで memoize) - unmount で必ず cleanup
5. フロー
5.1 Svelte 視点 — props / bind の向き
flowchart LR
subgraph STORE["Svelte store (ページ保持)"]
G[graph]
M[metricsData]
HID[highlightedIds]
end
TV[TopologyViewer]
SVG["svgElement"]
subgraph OV["オーバーレイ"]
WML[WeathermapLinkOverlay]
NSO[NodeStatusOverlay]
HLO[HighlightOverlay]
TTO[TooltipOverlay]
end
G -->|graph prop| TV
TV -->|bind:svgElement| SVG
TV -->|linkOverlay snippet| WML
M -->|metrics| WML
SVG -->|svgElement| NSO
M -->|status| NSO
SVG -->|svgElement| HLO
HID -->|highlightedIds| HLO
SVG -->|svgElement| TTO
G -->|graph| TTO
classDef store fill:#e6f0ff,stroke:#5080c0
class G,M,HID store
- graph は上から props で流れ下る(page → TopologyViewer → ShumokuRenderer)
- 構造化 overlay は renderer の snippet で各エンティティ内に入る
- サイドカー overlay は svgElement を受け取って自前で DOM 操作
- オーバーレイは store に直接触らない(props 経由のみ — テスタビリティと WC 対応のため)
5.2 時間軸 — one-shot と per-tick
flowchart LR
subgraph ONESHOT["One-shot (page mount / sheet 切替)"]
direction TB
G1["サーバ /graph で<br/>mapping.bandwidth を<br/>link.bandwidth に合流"]
G2["renderer: layout 計算<br/>(libavoid + custom Sugiyama)"]
G3["SvgEdge / SvgNode が<br/>SVG 出力"]
G4["camera attach"]
G1 --> G2 --> G3 --> G4
end
subgraph TICK["Per-tick (WS metrics 更新)"]
direction TB
T1["WS broadcast<br/>→ metricsData store"]
T2A["WeathermapLinkOverlay<br/>reactive render"]
T2B["NodeStatusOverlay<br/>$effect"]
T1 --> T2A
T1 --> T2B
end
subgraph EVENT["Event-driven"]
direction TB
E1["mouse hover"] --> E2["TooltipOverlay<br/>分類 + 配置"]
E3["highlight 要求"] --> E4["HighlightOverlay<br/>classList 切替"]
E5["wheel/drag"] --> E6["attachCamera<br/>d3-zoom transform"]
end
- One-shot は page mount / sheet 切替時に 1 回。重い layout 計算が ここに集中
- Per-tick は数秒おき。表示中エンティティの差分更新だけ走る
- Event-driven はユーザー操作毎。debounce / throttle は各 overlay 内で必要に応じて
6. オーバーレイ詳細
各オーバーレイの入力 / 出力 / DOM 操作 / カスタマイズ点をまとめる。
6.1 Weathermap(構造化、linkOverlay snippet)
用途: 各リンク上に流量を 2 レーンのドットで可視化、利用率に応じて base pipe を tint する。
入力:
context: LinkOverlayContext(renderer から)metrics: LinkFlowMetrics(in/out 利用率と bps)enabled/animation('full'/'reduced'/'off')
DOM 出力(g.link-group の中):
path.wm-overlay[data-direction="in"]path.wm-overlay[data-direction="out"]- 親
g.link-groupに.wm-activeクラス +--wm-base-color変数
CSS 契約(WeathermapLinkOverlay.svelte の <style>):
| 変数 | スコープ | 用途 |
|---|---|---|
--wm-color | レーン path | 色(利用率マップ or down 時赤) |
--wm-width | レーン path | 太さ = max(baseWidth / 2, 2) |
--wm-dash | レーン path | dash パターン(通常 "3 21"、down "8 4") |
--wm-opacity | レーン path | 透明度(通常 0.9、down 0.5) |
--wm-duration | レーン path | 周期(bpsToDurationMs(bps)、300ms–2s) |
--wm-play | レーン path | running / paused |
--wm-base-color | g.link-group | base pipe の tint 色 |
レーン幾何(stroke の内側に 2 本):
laneWidth = max(baseWidth / 2, 2)
laneOffset = baseWidth / 4
in lane: offset = +laneOffset (stroke 上半分)
out lane: offset = -laneOffset (stroke 下半分)
10G リンク(baseWidth 14)の例:
┌───── baseWidth 14 ─────┐
│ ═══ ═ ═══ ═══ ═══ │ ← out lane (width 7, offset -3.5)
│───────── base ──────── │ ← path.link (stroke は --wm-base-color で tint)
│ ═══ ═══ ═══ ═ │ ← in lane (width 7, offset +3.5)
└────────────────────────┘
アニメ(stroke-dashoffset の keyframe):
@keyframes wm-flow-in { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -24; } }
@keyframes wm-flow-out { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 24; } }
-24 / +24 は dash (3) + gap (21) = 24 px 。1 周期で dash+gap 1 単位
進む。
Base tint(SVG 属性を CSS で上書き):
:global(.wm-active > path.link) {
stroke: var(--wm-base-color, currentColor);
opacity: 0.55;
transition: stroke 200ms ease, opacity 200ms ease;
}
:global() は同コンポーネントで定義した変数を、外側 (g.link-group /
path.link は SvgEdge.svelte が出してる) のスコープに当てるため。
モード:
| モード | 動作 |
|---|---|
'full' | ドットアニメ + tint |
'reduced' | .wm-static で keyframe 停止、solid lane |
'off' | render 自体スキップ |
prefers-reduced-motion | @media で keyframe 強制停止(tint は残す) |
@media print | app.css 側でオーバーレイ非表示、tint 解除 |
6.2 Node Status(サイドカー、<svelte:head> で CSS 注入)
用途: ノードの状態を 3 軸独立 で可視化する。
| 軸 | 意味 | どこに出る |
|---|---|---|
| Mapping | このノードがモニタリング側のホストに割り当て済みか | metrics 入力に entry が無ければ装飾なし |
| Device 状態 | 機材自体が up/down か (= ICMP 等の直接信号) | g.node に status-* クラス → 枠(stroke) |
| Monitoring 健全性 | 監視路自体が機能してるか (= データ収集できてるか) | g.node に monitoring-* クラス + 右下に小バッジ inject |
3 軸が独立してるので、status-up + monitoring-paused(動いてるけど
メンテ中で値は捨ててる)や status-unknown + monitoring-failing
(監視到達不能、機材状態は不明)のような状態が互いに上書きせず
共存する。
入力:
svgElement: SVGSVGElementstatus: Record<nodeId, { status: string; monitoring?: string }>allowedStatuses?(default:['up', 'down', 'unknown', 'warning', 'degraded'])
DOM 操作:
- 各
g.node[data-id]にstatus-*とmonitoring-*のクラスを付与 monitoring-*が付くノードには<g class="shumoku-monitoring-badge">を append。中身は<circle r=5>一枚で、右下角にtransformで 位置決め。塗り色は CSS がmonitoring-*クラスから決める- バッジは冪等に再利用(re-render で要素が増えない)、cleanup で削除
CSS(<svelte:head> で <style id="shumoku-node-status-css"> を注入):
/* === Device 状態 (枠) === */
g.node.status-up .node-bg rect { stroke: #22c55e; stroke-width: 2px; }
g.node.status-down .node-bg rect {
stroke: #ef4444; stroke-width: 2.5px;
filter: drop-shadow(0 0 6px ...);
animation: shumoku-status-down-pulse 1.6s infinite alternate;
}
g.node.status-warning .node-bg rect { stroke: #f97316; ... }
g.node.status-degraded .node-bg rect { stroke: #eab308; ... }
g.node.status-unknown .node-bg rect { stroke: #6b7280; stroke-dasharray: 4 3; }
/* === Monitoring 健全性 (角バッジ) === */
g.node g.shumoku-monitoring-badge { pointer-events: none; }
g.node g.shumoku-monitoring-badge circle { stroke: #fff; stroke-width: 1.5; }
g.node.monitoring-healthy g.shumoku-monitoring-badge circle { fill: #22c55e; }
g.node.monitoring-failing g.shumoku-monitoring-badge circle { fill: #ef4444; }
g.node.monitoring-pending g.shumoku-monitoring-badge circle { fill: #9ca3af; }
g.node.monitoring-paused g.shumoku-monitoring-badge circle { fill: #3b82f6; }
CSS セレクタが .node-bg rect(枠側)と g.shumoku-monitoring-badge circle
(バッジ側)で完全に分離してるので、片方の rule が片方を上書きしない。
<svelte:head> 注入を使う理由は、複数の Svelte コンポーネントが
同じ rule 集合を共有するため(scoped style だと各コンポーネントで
重複定義になる)。
@media print と prefers-reduced-motion も同じ block で扱う
(バッジは print 時に display: none)。
プラグイン契約
プラグインが返す NodeMetrics(@shumoku/core の plugin-types.ts):
{
status: 'up' | 'down' | 'unknown' | 'warning' | 'degraded'
monitoring?: 'healthy' | 'failing' | 'pending' | 'paused'
monitoringError?: string // failing 時に "なぜ失敗したか" を残す
lastSeen?: number
}
monitoring は optional。プラグインが対応してない場合 undefined を
返してOK(その場合はバッジが出ず、枠だけで表現される)。
unmapped は entry をそもそも作らないことで表現する
(オーバーレイは entry の無いノードに対しては何もしない)。「mapped
だけど不明」と「mapped されてない」を視覚的に区別できる。
6.3 Highlight(サイドカー、reactive + imperative)
用途: 検索結果や選択フィルタで、特定ノードを強調(pulse + drop-shadow)、 他を dim する。
入力:
svgElementhighlightedIds?: ReadonlySet<string> | string[](reactive)attributeMatch?: { key, value }(reactive、属性ベースマッチ)dimOthers?: boolean— 非マッチを暗くするhighlightColor?: string—--highlight-colorに流すpulseAnimation?: boolean
imperative API(bind:this で取って使える):
apply(ids: Iterable<string>): voidapplyByAttribute(key, value): voidclearHighlight(): void
reactive と imperative が共存してるのは、ダッシュボードからの
クエリ駆動(reactive)と event 駆動(onhover callback など)の
両方に対応するため。
DOM 操作: マッチした g.node[data-id] に .node-highlighted、
dimOthers=true なら他全ての node + link-group に .node-dimmed。
CSS(<svelte:head> で <style id="shumoku-highlight-css">):
g.node.node-highlighted {
animation: var(--highlight-pulse, node-pulse) 0.5s ease-in-out infinite alternate;
}
g.node.node-highlighted rect,
g.node.node-highlighted circle,
g.node.node-highlighted path {
stroke: var(--highlight-color, #f59e0b) !important;
stroke-width: 3px !important;
filter: drop-shadow(0 0 8px ...);
}
g.node.node-dimmed,
g.link-group.node-dimmed {
opacity: 0.15;
transition: opacity 0.2s ease;
}
色とアニメは --highlight-color / --highlight-pulse 変数で外から
上書き可能。SVG element 側に style.setProperty で書き込む。
6.4 Tooltip(サイドカー、DOM event)
用途: ホバー時のツールチップ。何が hover されたか(node / subgraph / link)を分類して、カスタマイズ可能な内容を浮かべる。
入力:
svgElement/graph/enabled/delay/maxWidthcontent?: Snippet<{ hovered, graph }>— Svelte snippet で本体を描くcontentBuilder?: (hovered, graph) => string— 文字列で本体を返す関数
DOM 操作:
mouseover/mouseout/mousemoveをsvgElementに直接 attach (AbortControllerで cleanup)- ツールチップ本体は コンポーネントが直接
<div>を render(SVG 外、position: fixed) - DOM 改変は無し(svg 配下は読み取りのみ)
hit detection:
function classify(target: EventTarget): HoveredElement | null {
if (target.closest('g.node[data-id]')) return { kind: 'node', ... }
if (target.closest('g.subgraph[data-id]')) return { kind: 'subgraph', ... }
if (target.closest('g.link-group[data-link-id]')) return { kind: 'link', ... }
return null
}
closest() で renderer の class 名 / data-attribute を逆引きしてる。
これはサイドカー特有の “public DOM contract” 依存(2.3 節参照)。
6.5 Camera(関数 utility、@shumoku/renderer 提供)
用途: pan / zoom。マウスホイール = ズーム、トラックパッド 2 本指 = パン、ピンチ = ズーム、Alt+左 / 中ボタンドラッグ = パン。
API(関数1個):
const camera = attachCamera(svgElement, {
wheelMode: 'auto', // 'auto' | 'zoom' | 'pan'
panFilter: ...,
...
})
camera.zoomBy(factor)
camera.reset()
camera.panToNode(nodeId)
camera.detach()
実装: d3-zoom + wheel-gestures。マウスホイールとトラックパッドを
gesture 開始時に “sticky” に判別し、gesture 中は同じ扱いを維持
(zoom と pan が混ざらない)。詳しくは docs/ARCHITECTURE.md の
Camera 節 と libs/@shumoku/renderer/src/lib/camera.ts を参照。
DOM 操作: svgElement 上の wheel / pointerdown / pointermove
listener と、g.viewport の transform 属性。renderer の class 名
には依存しない(g.viewport は renderer が常に出す唯一の zoom 対象)。
TopologyViewer.svelte がこの関数を呼んで camera を attach し、
sheet 切替時に camera.reset() する。
7. Web Component 対応状況
7.1 公開 API 対応表
WC も Svelte 版も svgElement を外に出す契約。サイドカーはこの svgElement を受け取って動く。
| 面 | WC (<shumoku-renderer>) | Svelte (<ShumokuRenderer>) |
|---|---|---|
| graph | el.graph = ... setter | bind:nodes bind:links ... |
| theme | el.theme = ... setter | theme={...} prop |
| mode | `el.mode = ‘view’ | ‘edit’` |
| SVG 取得 | el.svgElement getter | bind:svgElement |
| イベント | el.onshumokuselect = fn | onselect={fn} |
| 命令的操作 | el.addNewNode(...) | renderer.addNewNode(...) |
| 構造化 overlay (snippet) | ✗ 未対応 | ✓ |
WC 版からの利用例(他フレームワーク / 素 HTML):
<shumoku-renderer id="topo"></shumoku-renderer>
<script>
const el = document.querySelector('#topo')
el.graph = myGraph
// サイドカーは同じ関数で動く
const camera = attachCamera(el.svgElement)
// 構造化 overlay は WC では未提供(後述)
</script>
7.2 Shadow DOM での CSS 注入問題
WC 版は Shadow DOM 内 に SVG を描く。サイドカーの DOM 操作は svgElement 配下で完結するので問題ないが、CSS 注入 は経路によって shadow に届く / 届かないが分かれる。
| 注入方式 | 使用しているオーバーレイ | Shadow DOM への到達 |
|---|---|---|
Svelte コンポーネントの <style>(scoped) | WeathermapLinkOverlay | 微妙 — Svelte ランタイムが生成する <style> は document.head 行き。:global() 内の rule は shadow に届かない |
<svelte:head> で <style> 注入 | NodeStatusOverlay / HighlightOverlay | ✗ — 常に document.head 行き、shadow に届かない |
要素の style.setProperty('--var', ...) | 全 overlay | ✓ — 要素自体に書くので shadow も関係なし |
attachCamera の SVG transform 操作 | Camera | ✓ — CSS 不使用 |
現状: Svelte 版しか使ってないので実害なし。WC 外部配布を始める
タイミングで、<svelte:head> の代わりに shadow root に注入 する
ヘルパーが必要:
function ensureStyleInRoot(svg: SVGSVGElement, id: string, css: string): void {
const root = svg.getRootNode()
const target = root instanceof ShadowRoot ? root : document.head
if (target.querySelector(`#${id}`)) return
const style = document.createElement('style')
style.id = id
style.textContent = css
target.appendChild(style)
}
これを各 overlay の $effect 開始時に呼ぶように変える(現状 NodeStatus
/ Highlight の <svelte:head> 部分を置き換える)。
構造化 overlay の WC 対応: snippet は Svelte 限定なので、WC では
linkOverlay 等の機能が使えない。代替案は以下のいずれか:
- HTML
<slot>で「リンクごとに複製」を実現(MutationObserver が必要) - imperative callback API(
el.onRenderLink = (link, group) => {...}) - 構造化 overlay は Svelte 専用と割り切り、WC は visual-only(camera
- tooltip)で運用
8. 不変条件・注意点
8.1 共通(全オーバーレイ)
- 入力は受け取った参照のみで完結(
svgElement/context)。document.querySelectorなどグローバルアクセスは使わない - renderer の DOM 属性は読むだけ、書き換えない(必要なら CSS 変数 や class 経由で上書き)
- unmount で必ず cleanup(class 削除 / CSS 変数削除 / listener abort / timer clear)
- 渡された domain object が変われば自動 reset(
$effect依存追跡 に任せる) - store には触らない(props 経由)
8.2 構造化 overlay
- snippet 内で
closest('g.link-group')等の class 名逆引きは禁止 — 必要な参照は context 経由で受け取る - snippet の return 直前に追加要素を render するだけで済むよう設計する (DOM 並び替えは不可)
8.3 サイドカー overlay
- renderer の public DOM contract(class 名 / data-attribute)に
依存する。ここは renderer 側の breaking change 扱い:
g.viewport、g.node[data-id]、g.link-group[data-link-id]、g.subgraph[data-id]、g.node-bg、path.link、port box
- DOM event の listener は
AbortControllerで必ず cleanup
8.4 Weathermap 固有
- レーン path のサンプリング: 直線は fast path、曲線は法線方向に 30+ 点
- pan/zoom 追従:
g.viewportの transform に乗るので毎フレーム同期不要 prefers-reduced-motionは keyframe だけ止める(色 / 太さ情報は残す)
付録 A: 関連ファイル
Renderer 中核
| ファイル | 役割 |
|---|---|
libs/@shumoku/renderer/src/components/ShumokuRenderer.svelte | 描画中核の Svelte コンポーネント |
libs/@shumoku/renderer/src/components/svg/SvgEdge.svelte | path.link + linkOverlay snippet host |
libs/@shumoku/renderer/src/components/svg/SvgNode.svelte | g.node + nodeOverlay snippet host |
libs/@shumoku/renderer/src/components/svg/SvgPort.svelte | port box + portOverlay snippet host |
libs/@shumoku/renderer/src/lib/overlays.ts | snippet の型定義(LinkOverlayContext 等) |
libs/@shumoku/renderer/src/lib/camera.ts | attachCamera (pan/zoom) |
libs/@shumoku/renderer/src/wc.svelte.ts | <shumoku-renderer> Web Component ラッパー |
構造化 overlay
| ファイル | 役割 |
|---|---|
apps/server/web/src/lib/components/topology/WeathermapLinkOverlay.svelte | linkOverlay snippet 内で flow lane を描く |
apps/server/web/src/lib/weathermap/index.ts | weathermap の色 / duration / 幾何ヘルパー |
サイドカー overlay
| ファイル | 役割 |
|---|---|
apps/server/web/src/lib/components/topology/NodeStatusOverlay.svelte | status-up/down/... クラスと CSS(svelte:head) |
apps/server/web/src/lib/components/topology/HighlightOverlay.svelte | ノード強調(reactive + imperative)とその CSS |
apps/server/web/src/lib/components/topology/TooltipOverlay.svelte | hover ツールチップ |
ホスト + サーバ側
| ファイル | 役割 |
|---|---|
apps/server/web/src/lib/components/topology/TopologyViewer.svelte | renderer mount + attachCamera + ViewerContext 出力 |
apps/server/api/src/api/topologies.ts (applyMappingBandwidth) | mapping の override を link.bandwidth に合流させるサーバ側ロジック |
libs/@shumoku/core/src/layout/link-utils.ts (getLinkWidth) | bandwidth → stroke-width の校正(single source of truth) |