feat: add rich hover and data ref diagnostics for flow diagrams

This commit is contained in:
nejimakibird 2026-07-06 09:50:47 +09:00
parent a34a6254d1
commit 9240edd8b4
9 changed files with 1169 additions and 60 deletions

351
main.js
View file

@ -2974,6 +2974,8 @@ function localizeDiagnosticMessage(message, language) {
[/^unresolved DFD flow source "([^"]+)"$/, (_match, source) => `DFD flow \u306E source "${source}" \u304C\u89E3\u6C7A\u3067\u304D\u307E\u305B\u3093\u3002`],
[/^unresolved DFD flow target "([^"]+)"$/, (_match, target) => `DFD flow \u306E target "${target}" \u304C\u89E3\u6C7A\u3067\u304D\u307E\u305B\u3093\u3002`],
[/^unresolved DFD object ref "([^"]+)"$/, (_match, ref) => `DFD\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u306E\u53C2\u7167 "${ref}" \u306E\u53C2\u7167\u5148\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002ID\u307E\u305F\u306F\u30D5\u30A1\u30A4\u30EB\u540D\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002`],
[/^DFD flow data reference "([^"]+)" could not be resolved\. Check the data\/model id or file name\.$/, (_match, ref) => `DFD flow data reference "${ref}" \u306E\u53C2\u7167\u5148\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002data/model \u306E id \u307E\u305F\u306F\u30D5\u30A1\u30A4\u30EB\u540D\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002`],
[/^Flow Diagram flow data reference "([^"]+)" could not be resolved\. Check the data\/model id or file name\.$/, (_match, ref) => `Flow Diagram flow data reference "${ref}" \u306E\u53C2\u7167\u5148\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002data/model \u306E id \u307E\u305F\u306F\u30D5\u30A1\u30A4\u30EB\u540D\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002`],
[/^DFD object ref "([^"]+)" could not be resolved\. Check the ID or file name\.$/, (_match, ref) => `DFD\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u306E\u53C2\u7167 "${ref}" \u306E\u53C2\u7167\u5148\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002ID\u307E\u305F\u306F\u30D5\u30A1\u30A4\u30EB\u540D\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002`],
[/^frontmatter parse error: unexpected list item "([^"]+)"$/, (_match, value) => `frontmatter \u306E\u89E3\u6790\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u4E88\u671F\u3057\u306A\u3044\u30EA\u30B9\u30C8\u9805\u76EE\u3067\u3059: "${value}"`],
[/^frontmatter parse error: malformed line "([^"]+)"$/, (_match, value) => `frontmatter \u306E\u89E3\u6790\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u884C\u306E\u5F62\u5F0F\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044: "${value}"`],
@ -4764,17 +4766,13 @@ function resolveDfdDiagramRelations(diagram, index) {
warnings.push(createDfdFlowShapeWarning(diagram.path, context, "datastore -> datastore"));
}
}
const flowData = resolveDfdFlowDataDisplay(flow.data, index, { suppressUnresolvedWarning: isFlowDiagram });
if (flowData.warning) {
warnings.push({
code: "unresolved-reference",
message: flowData.warning,
severity: "warning",
path: diagram.path,
field: "Flows",
context
});
}
const flowData = resolveDfdFlowDataDisplay(flow.data, index);
warnings.push(...resolveDfdFlowDataReferenceWarnings(
diagram,
flow.data,
index,
context
));
edges.push({
id: flow.id,
source: sourceEntry.node.id,
@ -5006,6 +5004,9 @@ function resolveDfdDiagramObjects(diagram, index, domainContext) {
domain,
rowIndex: entry.rowIndex,
local: !ref,
refReference: resolvedIdentity?.parsed,
refModelPath: resolvedIdentity?.resolvedModel?.path,
refModelType: resolvedIdentity?.resolvedModel?.fileType,
compatibilityMode: entry.compatibilityMode
},
object: resolvedObject
@ -5053,7 +5054,25 @@ function resolveDfdFlowEndpoint(value, registry, index) {
}
return null;
}
function resolveDfdFlowDataDisplay(rawValue, index, options = {}) {
function resolveDfdFlowDataReferenceWarnings(diagram, rawValue, index, context) {
const wikilinks = rawValue ? extractWikilinkReferences(rawValue) : [];
if (wikilinks.length === 0) {
return [];
}
const diagramLabel = diagram.schema === "flow_diagram" ? "Flow Diagram" : "DFD";
return wikilinks.filter((reference) => !resolveReferenceIdentity(reference, index).resolvedModel).map((reference) => ({
code: "unresolved-reference",
message: `${diagramLabel} flow data reference "${reference}" could not be resolved. Check the data/model id or file name.`,
severity: "warning",
path: diagram.path,
field: "data",
context: {
...context,
referenceValue: reference
}
}));
}
function resolveDfdFlowDataDisplay(rawValue, index) {
const trimmed = rawValue?.trim();
if (!trimmed) {
return {};
@ -5089,8 +5108,7 @@ function resolveDfdFlowDataDisplay(rawValue, index, options = {}) {
if (reference.target) {
return {
label: getReferenceDisplayName(trimmed),
reference,
warning: options.suppressUnresolvedWarning ? void 0 : `unresolved flow data reference "${trimmed}"`
reference
};
}
return { label: getReferenceDisplayName(trimmed), reference };
@ -16184,7 +16202,7 @@ function attachMermaidNodeInteractions(options) {
}
function attachGraphElementHoverPreview(options) {
const fallback = options.rootEl ?? getElementHoverFallback(options.targetEl);
if (!fallback || !options.target.linktext || !options.target.sourcePath) {
if (!fallback || !canShowGraphInteractionHover(options.target)) {
return () => void 0;
}
const controller = new AbortController();
@ -16210,7 +16228,7 @@ function attachGraphElementHoverPreview(options) {
debugName: options.debugName,
isDebugEnabled: options.isDebugEnabled
}, fallback, event, false);
const hoverLinkTarget = triggerGraphInteractionHoverPreview(
const hoverLinkTarget = triggerGraphInteractionHover(
options.app,
source,
resolveGraphHoverParent(options.targetEl, fallback, options.hoverParent),
@ -16269,7 +16287,11 @@ function clearGraphHoverState(state, action, debug, rootEl, event, suppressSynth
state.activeHoverSourcePath = "";
state.activeHoverTargetEl = null;
if (activeHoverTargetEl && !suppressSyntheticLeave) {
dispatchGraphHoverTargetLeave(activeHoverTargetEl);
if (isGraphFallbackHoverCard(activeHoverTargetEl)) {
activeHoverTargetEl.remove();
} else {
dispatchGraphHoverTargetLeave(activeHoverTargetEl);
}
} else if (hadActiveNode && !suppressSyntheticLeave) {
dispatchGraphHoverTargetLeave(rootEl);
}
@ -16331,24 +16353,29 @@ function getMermaidNodeInteractionFromEvent(event, interactions) {
function findMermaidNodeInteractionByTarget(interactions, target) {
return interactions.find((interaction) => interaction.target === target) ?? null;
}
function getGraphInteractionNativeTooltipText(target, fallbackTitle) {
return target.nativeTooltip ?? fallbackTitle ?? target.label ?? target.linktext;
}
function setMermaidNodeTitle(nodeEl, target, formatTitle) {
const titleText = formatTitle?.(target) ?? target.label ?? target.linktext;
const existingTitle = nodeEl.querySelector("title");
const titleText = getGraphInteractionNativeTooltipText(target, formatTitle?.(target));
if (!titleText) {
existingTitle?.remove();
return;
}
const doc = nodeEl.ownerDocument;
const title = nodeEl.querySelector("title") ?? doc.createElementNS("http://www.w3.org/2000/svg", "title");
const title = existingTitle ?? doc.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = titleText;
if (!title.parentElement) {
nodeEl.prepend(title);
}
}
function triggerMermaidNodeHoverPreview(options, source, targetEl, target, event, stateDebug) {
if (!target.linktext || !target.sourcePath) {
if (!canShowGraphInteractionHover(target)) {
return null;
}
const hoverParent = resolveGraphHoverParent(targetEl, options.rootEl, options.hoverParent);
return triggerGraphInteractionHoverPreview(
return triggerGraphInteractionHover(
options.app,
source,
hoverParent,
@ -16362,6 +16389,83 @@ function triggerMermaidNodeHoverPreview(options, source, targetEl, target, event
stateDebug
);
}
function canShowGraphInteractionHover(target) {
return Boolean(
target.previewLinktext && target.sourcePath || target.linktext && target.sourcePath && !target.hoverRows?.length || target.hoverRows?.length
);
}
function triggerGraphInteractionHover(app, source, hoverParent, targetEl, target, event, debug, stateDebug) {
const previewLinktext = getGraphInteractionPreviewLinktext(target);
if (previewLinktext) {
const previewTarget = { ...target, linktext: previewLinktext };
return triggerGraphInteractionHoverPreview(
app,
source,
hoverParent,
targetEl,
previewTarget,
event,
debug,
stateDebug
) ?? triggerGraphInteractionFallbackHover(hoverParent, target, event);
}
return triggerGraphInteractionFallbackHover(hoverParent, target, event);
}
function getGraphInteractionPreviewLinktext(target) {
const explicit = target.previewLinktext?.trim();
if (explicit) {
return explicit;
}
if (target.hoverRows?.length) {
return null;
}
const linktext = target.linktext?.trim();
return linktext && target.sourcePath ? linktext : null;
}
function triggerGraphInteractionFallbackHover(hoverParent, target, event) {
if (!target.hoverRows?.length) {
return null;
}
const card = createGraphFallbackHoverCard(hoverParent, target, event);
return { targetEl: card, reusableAnchorTargetUsed: false };
}
function createGraphFallbackHoverCard(hoverParent, target, event) {
const doc = hoverParent.ownerDocument;
const existing = hoverParent.querySelectorAll(".model-weave-graph-hover-card");
existing.forEach((element) => element.remove());
const card = doc.createElement("div");
card.className = "model-weave-graph-hover-card";
card.setAttribute("role", "tooltip");
const title = doc.createElement("div");
title.className = "model-weave-graph-hover-card-title";
title.textContent = target.hoverTitle ?? target.label ?? "Model Weave";
card.appendChild(title);
const rows = doc.createElement("dl");
rows.className = "model-weave-graph-hover-card-rows";
for (const row of target.hoverRows ?? []) {
const term = doc.createElement("dt");
term.textContent = row.label;
const description = doc.createElement("dd");
description.textContent = row.value?.trim() || "-";
rows.appendChild(term);
rows.appendChild(description);
}
card.appendChild(rows);
hoverParent.appendChild(card);
positionGraphFallbackHoverCard(card, hoverParent, event);
return card;
}
function positionGraphFallbackHoverCard(card, hoverParent, event) {
const view = hoverParent.ownerDocument.defaultView;
const viewportWidth = view?.innerWidth ?? event.clientX + 360;
const x = Math.min(Math.max(12, event.clientX + 14), Math.max(12, viewportWidth - 380));
const y = Math.max(12, event.clientY + 14);
card.style.left = `${x}px`;
card.style.top = `${y}px`;
}
function isGraphFallbackHoverCard(element) {
return element instanceof HTMLElement && element.classList.contains("model-weave-graph-hover-card");
}
function triggerGraphInteractionHoverPreview(app, source, hoverParent, targetEl, target, event, debug, stateDebug) {
if (!target.linktext || !target.sourcePath) {
return null;
@ -18536,9 +18640,11 @@ function renderDfdMermaidDiagram(diagram, options) {
shell3.root.appendChild(createObjectDetails(diagram, options?.dfdDetailLabels));
shell3.root.appendChild(createFlowDetails(diagram.edges, options?.dfdDetailLabels));
}
const interactionTargets = buildDfdMermaidInteractionTargets(
const sourcePath = options?.interactionSourcePath ?? diagram.diagram.path;
const flowHoverMetadata = isFlowDiagramModel(diagram.diagram) ? buildFlowDiagramHoverMetadata(diagram, sourcePath) : null;
const interactionTargets = flowHoverMetadata?.objects ?? buildDfdMermaidInteractionTargets(
diagram,
options?.interactionSourcePath ?? diagram.diagram.path
sourcePath
);
const ready = renderMermaidSourceIntoShell(shell3, {
source: buildDfdMermaidSource(diagram, options?.colorScheme),
@ -18553,6 +18659,14 @@ function renderDfdMermaidDiagram(diagram, options) {
sourcePanelCopyLabel: options?.sourcePanelCopyLabel,
showRenderDebug: !options?.forExport && options?.showMermaidRenderDebug === true
}).then(() => {
if (!options?.forExport && options?.app && flowHoverMetadata) {
attachFlowDiagramFlowHoverPreviews(
shell3.surface,
flowHoverMetadata.flows,
options.app,
options?.showMermaidRenderDebug === true
);
}
if (!options?.forExport && options?.app && interactionTargets.length > 0) {
attachMermaidNodeInteractions({
app: options.app,
@ -18562,8 +18676,19 @@ function renderDfdMermaidDiagram(diagram, options) {
nodeClassName: "model-weave-mermaid-interactive-node",
dragThreshold: 6,
isDebugEnabled: () => options?.showMermaidRenderDebug === true,
debugName: "DFD Mermaid",
formatTitle: (target) => target.label ? `${target.label} (${target.targetType ?? "model"})` : target.linktext
debugName: isFlowDiagramModel(diagram.diagram) ? "Flow Diagram Mermaid" : "DFD Mermaid",
formatTitle: (target) => target.label ? `${target.label} (${target.targetType ?? "model"})` : target.linktext,
openLinkText: isFlowDiagramModel(diagram.diagram) ? (target, event) => {
const linktext = getFlowDiagramOpenLinkText(target);
if (!linktext) {
return;
}
return options.app?.workspace.openLinkText(
linktext,
target.sourcePath,
event.ctrlKey || event.metaKey
);
} : void 0
});
}
}).catch(() => {
@ -18579,6 +18704,182 @@ function renderDfdMermaidDiagram(diagram, options) {
setMermaidRenderReadyPromise(shell3.root, ready);
return shell3.root;
}
function buildFlowDiagramHoverMetadata(diagram, sourcePath) {
if (!isFlowDiagramModel(diagram.diagram)) {
return { objects: [], flows: [] };
}
const objects = diagram.nodes.map((node) => {
const rows = buildFlowDiagramObjectHoverRows(node);
const hoverTitle = "Flow Object";
const target = {
mermaidId: toMermaidNodeId(node.id),
linktext: getFlowDiagramFallbackLinktext(sourcePath),
sourcePath,
label: node.label ?? node.id,
kind: "flow-diagram-object",
targetType: "flow_diagram_object",
filePath: getStringMetadata(node.metadata, "refModelPath"),
modelId: node.id,
modelType: "flow-diagram",
nodeId: node.id,
hoverTitle,
hoverRows: rows,
hoverText: formatHoverText(hoverTitle, rows),
previewLinktext: getResolvedPreviewLinktext(node.ref, getStringMetadata(node.metadata, "refModelPath")),
nativeTooltip: formatFlowDiagramObjectTooltip(node)
};
return target;
});
const flows = diagram.edges.map((edge, index) => {
const data = getStringMetadata(edge.metadata, "dataRaw") ?? edge.label;
const rows = buildFlowDiagramFlowHoverRows(edge, data);
const hoverTitle = "Flow";
return {
mermaidId: toFlowDiagramFlowMermaidId(edge, index),
linktext: getFlowDiagramFallbackLinktext(sourcePath),
sourcePath,
edgeId: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
data,
kind: "flow-diagram-flow",
targetType: "flow_diagram_flow",
modelId: edge.id,
modelType: "flow-diagram",
filePath: getStringMetadata(edge.metadata, "dataModelPath"),
hoverTitle,
hoverRows: rows,
hoverText: formatHoverText(hoverTitle, rows),
previewLinktext: getResolvedPreviewLinktext(data, getStringMetadata(edge.metadata, "dataModelPath")),
nativeTooltip: formatFlowDiagramFlowTooltip(edge, data)
};
});
return { objects, flows };
}
function formatFlowDiagramObjectTooltip(node) {
const lines = [
`Flow Object: ${node.id}`,
node.label,
`kind: ${typeof node.kind === "string" ? node.kind : "-"}`,
`domain: ${getStringMetadata(node.metadata, "domain") ?? "-"}`,
`ref: ${node.ref?.trim() || "-"}`
];
const notes = formatDiagramEdgeNotes(node.metadata?.notes);
if (notes) {
lines.push(notes);
}
return lines.filter((line) => Boolean(line && line.trim())).join("\n");
}
function formatFlowDiagramFlowTooltip(edge, data) {
const lines = [
`Flow: ${edge.id ?? "-"}`,
`${edge.source} -> ${edge.target}`,
`data: ${data?.trim() || "-"}`
];
const notes = formatDiagramEdgeNotes(edge.metadata?.notes);
if (notes) {
lines.push(notes);
}
return lines.join("\n");
}
function buildFlowDiagramObjectHoverRows(node) {
return [
{ label: "id", value: node.id },
{ label: "label", value: node.label },
{ label: "kind", value: typeof node.kind === "string" ? node.kind : void 0 },
{ label: "domain", value: getStringMetadata(node.metadata, "domain") },
{ label: "ref", value: node.ref },
{ label: "notes", value: formatDiagramEdgeNotes(node.metadata?.notes) }
];
}
function buildFlowDiagramFlowHoverRows(edge, data) {
return [
{ label: "id", value: edge.id },
{ label: "from", value: edge.source },
{ label: "to", value: edge.target },
{ label: "data", value: data },
{ label: "notes", value: formatDiagramEdgeNotes(edge.metadata?.notes) }
];
}
function formatHoverText(title, rows) {
return [
title,
...rows.map((row) => `${row.label}: ${formatHoverValue(row.value)}`)
].join("\n");
}
function formatHoverValue(value) {
const trimmed = value?.trim();
return trimmed || "-";
}
function getStringMetadata(metadata, key) {
const value = metadata?.[key];
return typeof value === "string" ? value : void 0;
}
function toFlowDiagramFlowMermaidId(edge, index) {
const id = edge.id?.trim() || `${edge.source}_${edge.target}_${index + 1}`;
return `FLOW_${index + 1}_${toMermaidNodeId(id)}`;
}
function getResolvedPreviewLinktext(rawReference, resolvedPath) {
if (!resolvedPath) {
return void 0;
}
const trimmed = rawReference?.trim();
if (!trimmed) {
return void 0;
}
const parsed = parseReferenceValue(trimmed);
return parsed?.kind === "wikilink" || parsed?.kind === "markdown_link" ? resolvedPath : void 0;
}
function getFlowDiagramFallbackLinktext(sourcePath) {
return sourcePath;
}
function getFlowDiagramOpenLinkText(target) {
const linktext = target.previewLinktext?.trim() || target.filePath?.trim();
return linktext ? linktext : null;
}
function attachFlowDiagramFlowHoverPreviews(rootEl, flowTargets, app, showMermaidRenderDebug) {
if (flowTargets.length === 0) {
return;
}
const svg = rootEl.querySelector("svg");
if (!svg) {
return;
}
const edgeLabels = Array.from(svg.querySelectorAll("g.edgeLabel"));
flowTargets.forEach((target, index) => {
const labelEl = edgeLabels[index];
if (!labelEl) {
return;
}
setSvgNativeTooltip(labelEl, target.nativeTooltip);
labelEl.addClass("model-weave-mermaid-interactive-flow");
labelEl.setAttribute("data-model-weave-flow-id", target.edgeId ?? target.mermaidId);
attachGraphElementHoverPreview({
app,
targetEl: labelEl,
target,
rootEl,
source: "model-weave",
isDebugEnabled: () => showMermaidRenderDebug,
debugName: "Flow Diagram Mermaid Flow"
});
});
}
function setSvgNativeTooltip(element, text) {
const existingTitle = element.querySelector("title");
const trimmed = text?.trim();
if (!trimmed) {
existingTitle?.remove();
element.removeAttribute("title");
return;
}
const title = existingTitle ?? element.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = trimmed;
if (!title.parentElement) {
element.prepend(title);
}
}
function buildDfdMermaidInteractionTargets(diagram, sourcePath) {
return diagram.nodes.map((node) => {
const object = getDfdObject(node);

View file

@ -1445,6 +1445,8 @@ export function localizeDiagnosticMessage(message: string, language?: string): s
[/^unresolved DFD flow source "([^"]+)"$/, (_match, source) => `DFD flow の source "${source}" が解決できません。`],
[/^unresolved DFD flow target "([^"]+)"$/, (_match, target) => `DFD flow の target "${target}" が解決できません。`],
[/^unresolved DFD object ref "([^"]+)"$/, (_match, ref) => `DFDオブジェクトの参照 "${ref}" の参照先が見つかりません。IDまたはファイル名を確認してください。`],
[/^DFD flow data reference "([^"]+)" could not be resolved\. Check the data\/model id or file name\.$/, (_match, ref) => `DFD flow data reference "${ref}" の参照先が見つかりません。data/model の id またはファイル名を確認してください。`],
[/^Flow Diagram flow data reference "([^"]+)" could not be resolved\. Check the data\/model id or file name\.$/, (_match, ref) => `Flow Diagram flow data reference "${ref}" の参照先が見つかりません。data/model の id またはファイル名を確認してください。`],
[/^DFD object ref "([^"]+)" could not be resolved\. Check the ID or file name\.$/, (_match, ref) => `DFDオブジェクトの参照 "${ref}" の参照先が見つかりません。IDまたはファイル名を確認してください。`],
[/^frontmatter parse error: unexpected list item "([^"]+)"$/, (_match, value) => `frontmatter の解析に失敗しました。予期しないリスト項目です: "${value}"`],
[/^frontmatter parse error: malformed line "([^"]+)"$/, (_match, value) => `frontmatter の解析に失敗しました。行の形式を確認してください: "${value}"`],

View file

@ -31,6 +31,7 @@ import { mergeDomainDiagramSources } from "./domain-diagram-resolver";
import { validateDomainEntries } from "../parsers/domains-parser";
import {
buildReferenceIdentityKeys,
extractWikilinkReferences,
findModelByReference,
getReferenceDisplayName,
parseReferenceValue,
@ -234,17 +235,13 @@ function resolveDfdDiagramRelations(
}
}
const flowData = resolveDfdFlowDataDisplay(flow.data, index, { suppressUnresolvedWarning: isFlowDiagram });
if (flowData.warning) {
warnings.push({
code: "unresolved-reference",
message: flowData.warning,
severity: "warning",
path: diagram.path,
field: "Flows",
context
});
}
const flowData = resolveDfdFlowDataDisplay(flow.data, index);
warnings.push(...resolveDfdFlowDataReferenceWarnings(
diagram,
flow.data,
index,
context
));
edges.push({
id: flow.id,
@ -536,6 +533,9 @@ function resolveDfdDiagramObjects(
domain,
rowIndex: entry.rowIndex,
local: !ref,
refReference: resolvedIdentity?.parsed,
refModelPath: resolvedIdentity?.resolvedModel?.path,
refModelType: resolvedIdentity?.resolvedModel?.fileType,
compatibilityMode: entry.compatibilityMode
},
object: resolvedObject
@ -599,15 +599,40 @@ function resolveDfdFlowEndpoint(
return null;
}
function resolveDfdFlowDataDisplay(
function resolveDfdFlowDataReferenceWarnings(
diagram: DfdDiagramModel | FlowDiagramModel,
rawValue: string | undefined,
index: ModelingVaultIndex,
options: { suppressUnresolvedWarning?: boolean } = {}
context: { section: string; rowIndex: number; relatedId?: string }
): ValidationWarning[] {
const wikilinks = rawValue ? extractWikilinkReferences(rawValue) : [];
if (wikilinks.length === 0) {
return [];
}
const diagramLabel = diagram.schema === "flow_diagram" ? "Flow Diagram" : "DFD";
return wikilinks
.filter((reference) => !resolveReferenceIdentity(reference, index).resolvedModel)
.map((reference) => ({
code: "unresolved-reference",
message: `${diagramLabel} flow data reference "${reference}" could not be resolved. Check the data/model id or file name.`,
severity: "warning",
path: diagram.path,
field: "data",
context: {
...context,
referenceValue: reference
}
}));
}
function resolveDfdFlowDataDisplay(
rawValue: string | undefined,
index: ModelingVaultIndex
): {
label?: string;
reference?: ReturnType<typeof parseReferenceValue>;
model?: ParsedFileModel | null;
warning?: string;
} {
const trimmed = rawValue?.trim();
if (!trimmed) {
@ -653,8 +678,7 @@ function resolveDfdFlowDataDisplay(
if (reference.target) {
return {
label: getReferenceDisplayName(trimmed),
reference,
warning: options.suppressUnresolvedWarning ? undefined : `unresolved flow data reference "${trimmed}"`
reference
};
}

View file

@ -24,8 +24,14 @@ import {
setMermaidRenderReadyPromise
} from "./mermaid-shared";
import { sanitizeMermaidId } from "./mermaid-helpers";
import { parseReferenceValue } from "../core/reference-resolver";
import { modelWeaveText } from "../i18n/language";
import { attachMermaidNodeInteractions, type GraphInteractionTarget } from "../views/mermaid-node-interactions";
import {
attachGraphElementHoverPreview,
attachMermaidNodeInteractions,
type GraphInteractionHoverRow,
type GraphInteractionTarget
} from "../views/mermaid-node-interactions";
export interface DfdDetailLabels {
displayedObjects: string;
@ -37,6 +43,18 @@ export interface DfdDetailLabels {
unresolved: string;
}
export interface FlowDiagramHoverMetadata {
objects: GraphInteractionTarget[];
flows: FlowDiagramFlowHoverTarget[];
}
export interface FlowDiagramFlowHoverTarget extends GraphInteractionTarget {
edgeId?: string;
source: string;
target: string;
data?: string;
}
export function renderDfdMermaidDiagram(
diagram: ResolvedDiagram,
options?: {
@ -94,9 +112,13 @@ export function renderDfdMermaidDiagram(
shell.root.appendChild(createFlowDetails(diagram.edges, options?.dfdDetailLabels));
}
const interactionTargets = buildDfdMermaidInteractionTargets(
const sourcePath = options?.interactionSourcePath ?? diagram.diagram.path;
const flowHoverMetadata = isFlowDiagramModel(diagram.diagram)
? buildFlowDiagramHoverMetadata(diagram, sourcePath)
: null;
const interactionTargets = flowHoverMetadata?.objects ?? buildDfdMermaidInteractionTargets(
diagram,
options?.interactionSourcePath ?? diagram.diagram.path
sourcePath
);
const ready = renderMermaidSourceIntoShell(shell, {
@ -113,6 +135,14 @@ export function renderDfdMermaidDiagram(
showRenderDebug:
!options?.forExport && options?.showMermaidRenderDebug === true
}).then(() => {
if (!options?.forExport && options?.app && flowHoverMetadata) {
attachFlowDiagramFlowHoverPreviews(
shell.surface,
flowHoverMetadata.flows,
options.app,
options?.showMermaidRenderDebug === true
);
}
if (!options?.forExport && options?.app && interactionTargets.length > 0) {
attachMermaidNodeInteractions({
app: options.app,
@ -122,10 +152,23 @@ export function renderDfdMermaidDiagram(
nodeClassName: "model-weave-mermaid-interactive-node",
dragThreshold: 6,
isDebugEnabled: () => options?.showMermaidRenderDebug === true,
debugName: "DFD Mermaid",
debugName: isFlowDiagramModel(diagram.diagram) ? "Flow Diagram Mermaid" : "DFD Mermaid",
formatTitle: (target) => target.label
? `${target.label} (${target.targetType ?? "model"})`
: target.linktext
: target.linktext,
openLinkText: isFlowDiagramModel(diagram.diagram)
? (target, event) => {
const linktext = getFlowDiagramOpenLinkText(target);
if (!linktext) {
return;
}
return options.app?.workspace.openLinkText(
linktext,
target.sourcePath,
event.ctrlKey || event.metaKey
);
}
: undefined
});
}
}).catch(() => {
@ -144,6 +187,215 @@ export function renderDfdMermaidDiagram(
}
export function buildFlowDiagramHoverMetadata(
diagram: ResolvedDiagram,
sourcePath: string
): FlowDiagramHoverMetadata {
if (!isFlowDiagramModel(diagram.diagram)) {
return { objects: [], flows: [] };
}
const objects = diagram.nodes.map((node) => {
const rows = buildFlowDiagramObjectHoverRows(node);
const hoverTitle = "Flow Object";
const target: GraphInteractionTarget = {
mermaidId: toMermaidNodeId(node.id),
linktext: getFlowDiagramFallbackLinktext(sourcePath),
sourcePath,
label: node.label ?? node.id,
kind: "flow-diagram-object",
targetType: "flow_diagram_object",
filePath: getStringMetadata(node.metadata, "refModelPath"),
modelId: node.id,
modelType: "flow-diagram",
nodeId: node.id,
hoverTitle,
hoverRows: rows,
hoverText: formatHoverText(hoverTitle, rows),
previewLinktext: getResolvedPreviewLinktext(node.ref, getStringMetadata(node.metadata, "refModelPath")),
nativeTooltip: formatFlowDiagramObjectTooltip(node)
};
return target;
});
const flows = diagram.edges.map((edge, index) => {
const data = getStringMetadata(edge.metadata, "dataRaw") ?? edge.label;
const rows = buildFlowDiagramFlowHoverRows(edge, data);
const hoverTitle = "Flow";
return {
mermaidId: toFlowDiagramFlowMermaidId(edge, index),
linktext: getFlowDiagramFallbackLinktext(sourcePath),
sourcePath,
edgeId: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
data,
kind: "flow-diagram-flow",
targetType: "flow_diagram_flow",
modelId: edge.id,
modelType: "flow-diagram",
filePath: getStringMetadata(edge.metadata, "dataModelPath"),
hoverTitle,
hoverRows: rows,
hoverText: formatHoverText(hoverTitle, rows),
previewLinktext: getResolvedPreviewLinktext(data, getStringMetadata(edge.metadata, "dataModelPath")),
nativeTooltip: formatFlowDiagramFlowTooltip(edge, data)
};
});
return { objects, flows };
}
function formatFlowDiagramObjectTooltip(node: DiagramNode): string {
const lines = [
`Flow Object: ${node.id}`,
node.label,
`kind: ${typeof node.kind === "string" ? node.kind : "-"}`,
`domain: ${getStringMetadata(node.metadata, "domain") ?? "-"}`,
`ref: ${node.ref?.trim() || "-"}`
];
const notes = formatDiagramEdgeNotes(node.metadata?.notes);
if (notes) {
lines.push(notes);
}
return lines.filter((line): line is string => Boolean(line && line.trim())).join("\n");
}
function formatFlowDiagramFlowTooltip(edge: DiagramEdge, data: string | undefined): string {
const lines = [
`Flow: ${edge.id ?? "-"}`,
`${edge.source} -> ${edge.target}`,
`data: ${data?.trim() || "-"}`
];
const notes = formatDiagramEdgeNotes(edge.metadata?.notes);
if (notes) {
lines.push(notes);
}
return lines.join("\n");
}
function buildFlowDiagramObjectHoverRows(node: DiagramNode): GraphInteractionHoverRow[] {
return [
{ label: "id", value: node.id },
{ label: "label", value: node.label },
{ label: "kind", value: typeof node.kind === "string" ? node.kind : undefined },
{ label: "domain", value: getStringMetadata(node.metadata, "domain") },
{ label: "ref", value: node.ref },
{ label: "notes", value: formatDiagramEdgeNotes(node.metadata?.notes) }
];
}
function buildFlowDiagramFlowHoverRows(edge: DiagramEdge, data: string | undefined): GraphInteractionHoverRow[] {
return [
{ label: "id", value: edge.id },
{ label: "from", value: edge.source },
{ label: "to", value: edge.target },
{ label: "data", value: data },
{ label: "notes", value: formatDiagramEdgeNotes(edge.metadata?.notes) }
];
}
function formatHoverText(title: string, rows: GraphInteractionHoverRow[]): string {
return [
title,
...rows.map((row) => `${row.label}: ${formatHoverValue(row.value)}`)
].join("\n");
}
function formatHoverValue(value: string | undefined): string {
const trimmed = value?.trim();
return trimmed || "-";
}
function getStringMetadata(metadata: Record<string, unknown> | undefined, key: string): string | undefined {
const value = metadata?.[key];
return typeof value === "string" ? value : undefined;
}
function toFlowDiagramFlowMermaidId(edge: DiagramEdge, index: number): string {
const id = edge.id?.trim() || `${edge.source}_${edge.target}_${index + 1}`;
return `FLOW_${index + 1}_${toMermaidNodeId(id)}`;
}
function getResolvedPreviewLinktext(rawReference: string | undefined, resolvedPath: string | undefined): string | undefined {
if (!resolvedPath) {
return undefined;
}
const trimmed = rawReference?.trim();
if (!trimmed) {
return undefined;
}
const parsed = parseReferenceValue(trimmed);
return parsed?.kind === "wikilink" || parsed?.kind === "markdown_link"
? resolvedPath
: undefined;
}
function getFlowDiagramFallbackLinktext(sourcePath: string): string {
return sourcePath;
}
function getFlowDiagramOpenLinkText(target: GraphInteractionTarget): string | null {
const linktext = target.previewLinktext?.trim() || target.filePath?.trim();
return linktext ? linktext : null;
}
function attachFlowDiagramFlowHoverPreviews(
rootEl: HTMLElement,
flowTargets: FlowDiagramFlowHoverTarget[],
app: App,
showMermaidRenderDebug: boolean
): void {
if (flowTargets.length === 0) {
return;
}
const svg = rootEl.querySelector<SVGElement>("svg");
if (!svg) {
return;
}
const edgeLabels = Array.from(svg.querySelectorAll<SVGElement>("g.edgeLabel"));
flowTargets.forEach((target, index) => {
const labelEl = edgeLabels[index];
if (!labelEl) {
return;
}
setSvgNativeTooltip(labelEl, target.nativeTooltip);
labelEl.addClass("model-weave-mermaid-interactive-flow");
labelEl.setAttribute("data-model-weave-flow-id", target.edgeId ?? target.mermaidId);
attachGraphElementHoverPreview({
app,
targetEl: labelEl,
target,
rootEl,
source: "model-weave",
isDebugEnabled: () => showMermaidRenderDebug,
debugName: "Flow Diagram Mermaid Flow"
});
});
}
function setSvgNativeTooltip(element: SVGElement, text: string | undefined): void {
const existingTitle = element.querySelector("title");
const trimmed = text?.trim();
if (!trimmed) {
existingTitle?.remove();
element.removeAttribute("title");
return;
}
const title = existingTitle
?? element.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = trimmed;
if (!title.parentElement) {
element.prepend(title);
}
}
function buildDfdMermaidInteractionTargets(
diagram: ResolvedDiagram,
sourcePath: string

View file

@ -12,6 +12,16 @@ export interface GraphInteractionTarget {
modelType?: string;
nodeId?: string;
status?: string;
hoverTitle?: string;
hoverRows?: GraphInteractionHoverRow[];
hoverText?: string;
previewLinktext?: string;
nativeTooltip?: string;
}
export interface GraphInteractionHoverRow {
label: string;
value?: string;
}
export interface AttachMermaidNodeInteractionsOptions {
@ -311,7 +321,7 @@ export function attachGraphElementHoverPreview(
options: AttachGraphElementHoverPreviewOptions
): () => void {
const fallback = options.rootEl ?? getElementHoverFallback(options.targetEl);
if (!fallback || !options.target.linktext || !options.target.sourcePath) {
if (!fallback || !canShowGraphInteractionHover(options.target)) {
return () => undefined;
}
@ -342,7 +352,7 @@ export function attachGraphElementHoverPreview(
debugName: options.debugName,
isDebugEnabled: options.isDebugEnabled
}, fallback, event, false);
const hoverLinkTarget = triggerGraphInteractionHoverPreview(
const hoverLinkTarget = triggerGraphInteractionHover(
options.app,
source,
resolveGraphHoverParent(options.targetEl, fallback, options.hoverParent),
@ -420,7 +430,11 @@ function clearGraphHoverState(
state.activeHoverTargetEl = null;
if (activeHoverTargetEl && !suppressSyntheticLeave) {
dispatchGraphHoverTargetLeave(activeHoverTargetEl);
if (isGraphFallbackHoverCard(activeHoverTargetEl)) {
activeHoverTargetEl.remove();
} else {
dispatchGraphHoverTargetLeave(activeHoverTargetEl);
}
} else if (hadActiveNode && !suppressSyntheticLeave) {
dispatchGraphHoverTargetLeave(rootEl);
}
@ -518,18 +532,27 @@ function findMermaidNodeInteractionByTarget(
return interactions.find((interaction) => interaction.target === target) ?? null;
}
export function getGraphInteractionNativeTooltipText(
target: GraphInteractionTarget,
fallbackTitle?: string
): string | undefined {
return target.nativeTooltip ?? fallbackTitle ?? target.label ?? target.linktext;
}
function setMermaidNodeTitle(
nodeEl: SVGElement,
target: GraphInteractionTarget,
formatTitle: AttachMermaidNodeInteractionsOptions["formatTitle"]
): void {
const titleText = formatTitle?.(target) ?? target.label ?? target.linktext;
const existingTitle = nodeEl.querySelector("title");
const titleText = getGraphInteractionNativeTooltipText(target, formatTitle?.(target));
if (!titleText) {
existingTitle?.remove();
return;
}
const doc = nodeEl.ownerDocument;
const title = nodeEl.querySelector("title") ?? doc.createElementNS("http://www.w3.org/2000/svg", "title");
const title = existingTitle ?? doc.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = titleText;
if (!title.parentElement) {
nodeEl.prepend(title);
@ -544,13 +567,13 @@ function triggerMermaidNodeHoverPreview(
event: MouseEvent,
stateDebug?: HoverStateDebugFields
): HoverLinkTargetResolution | null {
if (!target.linktext || !target.sourcePath) {
if (!canShowGraphInteractionHover(target)) {
return null;
}
const hoverParent = resolveGraphHoverParent(targetEl, options.rootEl, options.hoverParent);
return triggerGraphInteractionHoverPreview(
return triggerGraphInteractionHover(
options.app,
source,
hoverParent,
@ -565,6 +588,119 @@ function triggerMermaidNodeHoverPreview(
);
}
function canShowGraphInteractionHover(target: GraphInteractionTarget): boolean {
return Boolean(
(target.previewLinktext && target.sourcePath) ||
(target.linktext && target.sourcePath && !target.hoverRows?.length) ||
target.hoverRows?.length
);
}
function triggerGraphInteractionHover(
app: App,
source: string,
hoverParent: HTMLElement,
targetEl: HTMLElement | SVGElement,
target: GraphInteractionTarget,
event: MouseEvent,
debug?: HoverPreviewDebugOptions,
stateDebug?: HoverStateDebugFields
): HoverLinkTargetResolution | null {
const previewLinktext = getGraphInteractionPreviewLinktext(target);
if (previewLinktext) {
const previewTarget = { ...target, linktext: previewLinktext };
return triggerGraphInteractionHoverPreview(
app,
source,
hoverParent,
targetEl,
previewTarget,
event,
debug,
stateDebug
) ?? triggerGraphInteractionFallbackHover(hoverParent, target, event);
}
return triggerGraphInteractionFallbackHover(hoverParent, target, event);
}
function getGraphInteractionPreviewLinktext(target: GraphInteractionTarget): string | null {
const explicit = target.previewLinktext?.trim();
if (explicit) {
return explicit;
}
if (target.hoverRows?.length) {
return null;
}
const linktext = target.linktext?.trim();
return linktext && target.sourcePath ? linktext : null;
}
function triggerGraphInteractionFallbackHover(
hoverParent: HTMLElement,
target: GraphInteractionTarget,
event: MouseEvent
): HoverLinkTargetResolution | null {
if (!target.hoverRows?.length) {
return null;
}
const card = createGraphFallbackHoverCard(hoverParent, target, event);
return { targetEl: card, reusableAnchorTargetUsed: false };
}
function createGraphFallbackHoverCard(
hoverParent: HTMLElement,
target: GraphInteractionTarget,
event: MouseEvent
): HTMLElement {
const doc = hoverParent.ownerDocument;
const existing = hoverParent.querySelectorAll<HTMLElement>(".model-weave-graph-hover-card");
existing.forEach((element) => element.remove());
const card = doc.createElement("div");
card.className = "model-weave-graph-hover-card";
card.setAttribute("role", "tooltip");
const title = doc.createElement("div");
title.className = "model-weave-graph-hover-card-title";
title.textContent = target.hoverTitle ?? target.label ?? "Model Weave";
card.appendChild(title);
const rows = doc.createElement("dl");
rows.className = "model-weave-graph-hover-card-rows";
for (const row of target.hoverRows ?? []) {
const term = doc.createElement("dt");
term.textContent = row.label;
const description = doc.createElement("dd");
description.textContent = row.value?.trim() || "-";
rows.appendChild(term);
rows.appendChild(description);
}
card.appendChild(rows);
hoverParent.appendChild(card);
positionGraphFallbackHoverCard(card, hoverParent, event);
return card;
}
function positionGraphFallbackHoverCard(
card: HTMLElement,
hoverParent: HTMLElement,
event: MouseEvent
): void {
const view = hoverParent.ownerDocument.defaultView;
const viewportWidth = view?.innerWidth ?? event.clientX + 360;
const x = Math.min(Math.max(12, event.clientX + 14), Math.max(12, viewportWidth - 380));
const y = Math.max(12, event.clientY + 14);
card.style.left = `${x}px`;
card.style.top = `${y}px`;
}
function isGraphFallbackHoverCard(element: HTMLElement | SVGElement): element is HTMLElement {
return element instanceof HTMLElement && element.classList.contains("model-weave-graph-hover-card");
}
function triggerGraphInteractionHoverPreview(
app: App,
source: string,

View file

@ -1992,3 +1992,43 @@ body.model-weave-focus-mode-active .popover.hover-popover {
text-overflow: ellipsis;
white-space: nowrap;
}
.model-weave-graph-hover-card {
position: fixed;
z-index: 1000;
max-width: min(360px, calc(100% - 24px));
padding: 10px 12px;
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
background: var(--background-primary);
box-shadow: var(--shadow-s);
color: var(--text-normal);
font-size: var(--font-ui-small);
line-height: 1.35;
pointer-events: none;
}
.model-weave-graph-hover-card-title {
margin-bottom: 6px;
font-weight: 700;
}
.model-weave-graph-hover-card-rows {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 4px 8px;
margin: 0;
}
.model-weave-graph-hover-card-rows dt {
margin: 0;
color: var(--text-muted);
font-weight: 600;
}
.model-weave-graph-hover-card-rows dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}

View file

@ -32,7 +32,8 @@ await build({
'export { renderClassDiagram } from "./src/renderers/class-renderer";',
'export { buildVaultIndex, ensureVaultValidation, replaceVaultIndexFile } from "./src/core/vault-index";',
'export { resolveObjectContext } from "./src/core/object-context-resolver";',
'export { buildCurrentObjectDiagnostics, localizeDiagnosticMessage } from "./src/core/current-file-diagnostics";'
'export { buildCurrentObjectDiagnostics, localizeDiagnosticMessage } from "./src/core/current-file-diagnostics";',
'export { getExpectedHeaderForDiagnostic } from "./src/core/diagnostic-section-guidance";'
].join("\n"),
resolveDir: ".",
sourcefile: "test-domains-model-entry.ts",
@ -125,6 +126,7 @@ const {
buildCurrentObjectDiagnostics,
ensureVaultValidation,
localizeDiagnosticMessage,
getExpectedHeaderForDiagnostic,
resolveDiagramRelations,
replaceVaultIndexFile
} = await import(
@ -1849,6 +1851,79 @@ test("DFD files without local Domains remain compatible", () => {
assert.equal(warnings.length, 0);
});
function dfdDataWarningMarkdown(dataValue) {
return `${dfdFrontmatter}
## Objects
| id | label | kind | ref | notes |
|---|---|---|---|---|
| user | User | external | | User |
| pick | Pick items | process | | Pick |
## Flows
| id | from | to | data | notes |
|---|---|---|---|---|
| request | user | pick | ${dataValue} | User request |
`;
}
function getDfdFlowDataWarnings(dataValue, extraFiles = []) {
const { resolved } = resolveDfdWithFiles([
{ path: "DFD-SHIPPING.md", content: dfdDataWarningMarkdown(dataValue) },
...extraFiles
]);
return resolved.warnings.filter((warning) =>
warning.code === "unresolved-reference" && /flow data reference/.test(warning.message)
);
}
test("dfd_diagram Flows.data plain text does not warn", () => {
assert.equal(getDfdFlowDataWarnings("DATA-ORDER-DRAFT").length, 0);
assert.equal(getDfdFlowDataWarnings("Search condition").length, 0);
});
test("dfd_diagram Flows.data empty value does not warn", () => {
assert.equal(getDfdFlowDataWarnings("").length, 0);
});
test("dfd_diagram Flows.data resolved Wikilink does not warn", () => {
const warnings = getDfdFlowDataWarnings("[[DATA-ORDER-DRAFT]]", [
{ path: "DATA-ORDER-DRAFT.md", content: "---\ntype: data_object\nid: DATA-ORDER-DRAFT\nname: Order Draft\n---\n" }
]);
assert.equal(warnings.length, 0);
});
test("dfd_diagram Flows.data unresolved Wikilink emits unresolved-reference warning", () => {
const warnings = getDfdFlowDataWarnings("[[DATA-NOT-EXISTS]]");
assert.equal(warnings.length, 1);
assert.equal(warnings[0].message, 'DFD flow data reference "[[DATA-NOT-EXISTS]]" could not be resolved. Check the data/model id or file name.');
assert.equal(warnings[0].severity, "warning");
assert.equal(warnings[0].field, "data");
assert.equal(warnings[0].context.section, "Flows");
assert.equal(warnings[0].context.rowIndex, 1);
assert.equal(warnings[0].context.referenceValue, "[[DATA-NOT-EXISTS]]");
assert.equal(getExpectedHeaderForDiagnostic(warnings[0]), null);
assert.notEqual(warnings[0].context.referenceKind, "local-object-id");
});
test("dfd_diagram Flows.data multiple Wikilinks warn only for unresolved links", () => {
const warnings = getDfdFlowDataWarnings("[[DATA-A]], [[DATA-B]]", [
{ path: "DATA-A.md", content: "---\ntype: data_object\nid: DATA-A\nname: Data A\n---\n" }
]);
assert.deepEqual(warnings.map((warning) => warning.context.referenceValue), ["[[DATA-B]]"]);
});
test("localizes DFD flow data reference diagnostics", () => {
assert.equal(
localizeDiagnosticMessage('DFD flow data reference "[[DATA-NOT-EXISTS]]" could not be resolved. Check the data/model id or file name.', "ja"),
'DFD flow data reference "[[DATA-NOT-EXISTS]]" の参照先が見つかりません。data/model の id またはファイル名を確認してください。'
);
});
test("DFD Objects refs accept non-DFD Model Weave assets without unresolved warnings", () => {
const dfd = `---
type: dfd_diagram

View file

@ -13,7 +13,7 @@ await build({
'export { isDiagramPreviewRouteFileType, isDfdLikeDiagramPreviewFileType } from "./src/core/preview-routing";',
'export { isModelWeavePreviewSupportedFileType } from "./src/core/supported-formats";',
'export { resolveDiagramRelations } from "./src/core/relation-resolver";',
'export { buildDfdMermaidSource, getDfdMermaidColorSchemeTargets } from "./src/renderers/dfd-mermaid";',
'export { buildDfdMermaidSource, buildFlowDiagramHoverMetadata, getDfdMermaidColorSchemeTargets } from "./src/renderers/dfd-mermaid";',
'export { getAppliedColorSchemeRowsForTargets } from "./src/core/color-scheme";',
'export { buildCurrentDiagramDiagnostics } from "./src/core/current-file-diagnostics";',
'export { createModelWeaveTranslator } from "./src/i18n/messages";',
@ -81,6 +81,7 @@ await build({
const {
buildCurrentDiagramDiagnostics,
buildDfdMermaidSource,
buildFlowDiagramHoverMetadata,
createModelWeaveTranslator,
getAppliedColorSchemeLowerPaneSlot,
getAppliedColorSchemeRowsForTargets,
@ -253,6 +254,255 @@ test("flow_diagram Flows.data appears as edge label", () => {
assert.match(source, /order_process -->\|Order result\| session_store/);
});
function getFlowDiagramDataWarnings(markdown, extraFiles = []) {
const { model, resolved } = resolveFlow(markdown, extraFiles);
return buildCurrentDiagramDiagnostics({ diagram: model }, resolved.warnings)
.filter((warning) => warning.code === "unresolved-reference" && /flow data reference/.test(warning.message));
}
test("flow_diagram Flows.data plain text does not warn", () => {
const warnings = getFlowDiagramDataWarnings(flowMarkdown.replace("[[DATA-ORDER-REQUEST]]", "DATA-ORDER-DRAFT"));
assert.equal(warnings.length, 0);
});
test("flow_diagram Flows.data empty value does not warn", () => {
const warnings = getFlowDiagramDataWarnings(flowMarkdown.replace("[[DATA-ORDER-REQUEST]]", ""));
assert.equal(warnings.length, 0);
});
test("flow_diagram Flows.data resolved Wikilink does not warn", () => {
const warnings = getFlowDiagramDataWarnings(flowMarkdown, [
{ path: "DATA-ORDER-REQUEST.md", content: "---\ntype: data_object\nid: DATA-ORDER-REQUEST\nname: Order Request\n---\n" }
]);
assert.equal(warnings.length, 0);
});
test("flow_diagram Flows.data unresolved Wikilink emits unresolved-reference warning", () => {
const warnings = getFlowDiagramDataWarnings(flowMarkdown);
const diagnostic = warnings.find((warning) => warning.context.referenceValue === "[[DATA-ORDER-REQUEST]]");
assert.ok(diagnostic);
assert.equal(diagnostic.message, 'Flow Diagram flow data reference "[[DATA-ORDER-REQUEST]]" could not be resolved. Check the data/model id or file name.');
assert.equal(diagnostic.severity, "warning");
assert.equal(diagnostic.field, "data");
assert.equal(diagnostic.context.section, "Flows");
assert.equal(diagnostic.context.rowIndex, 1);
assert.equal(getExpectedHeaderForDiagnostic(diagnostic), null);
const t = createModelWeaveTranslator("en");
const details = getDiagnosticDetailEntries(diagnostic, t);
assert.equal(details.some((entry) => /local .*Objects.* table/.test(entry.value)), false);
});
test("flow_diagram Flows.data multiple Wikilinks warn only for unresolved links", () => {
const markdown = flowMarkdown.replace("[[DATA-ORDER-REQUEST]]", "[[DATA-A]], [[DATA-B]]");
const warnings = getFlowDiagramDataWarnings(markdown, [
{ path: "DATA-A.md", content: "---\ntype: data_object\nid: DATA-A\nname: Data A\n---\n" }
]);
assert.deepEqual(warnings.map((warning) => warning.context.referenceValue), ["[[DATA-B]]"]);
});
test("flow_diagram object hover metadata is generated for each Objects row", () => {
const { resolved } = resolveFlow();
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
assert.equal(metadata.objects.length, resolved.nodes.length);
assert.deepEqual(metadata.objects.map((target) => target.mermaidId), [
"order_screen",
"order_process",
"session_store",
"data_store",
"mystery"
]);
});
test("flow_diagram object hover metadata includes row details and raw wikilink ref", () => {
const { resolved } = resolveFlow();
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.objects.find((entry) => entry.nodeId === "order_screen");
assert.ok(target);
assert.equal(target.hoverTitle, "Flow Object");
assert.deepEqual(
Object.fromEntries(target.hoverRows.map((row) => [row.label, row.value])),
{
id: "order_screen",
label: "Order Screen",
kind: "screen",
domain: "order",
ref: "[[SCR-ORDER]]",
notes: "Source screen"
}
);
assert.match(target.hoverText, /Flow Object/);
assert.match(target.hoverText, /ref: \[\[SCR-ORDER\]\]/);
});
test("flow_diagram object hover metadata exposes resolved file path for resolved wikilink ref", () => {
const { resolved } = resolveFlow(flowMarkdown, [
{ path: "SCR-ORDER.md", content: "---\ntype: screen\nid: SCR-ORDER\nname: Order Screen\n---\n" }
]);
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.objects.find((entry) => entry.nodeId === "order_screen");
assert.ok(target);
assert.equal(target.previewLinktext, "SCR-ORDER.md");
assert.equal(target.filePath, "SCR-ORDER.md");
assert.notEqual(target.previewLinktext, "[[SCR-ORDER]]");
assert.match(target.nativeTooltip, /Flow Object: order_screen/);
assert.match(target.nativeTooltip, /ref: \[\[SCR-ORDER\]\]/);
assert.ok(target.hoverRows.length > 0);
});
test("flow_diagram plain or unresolved object ref falls back to custom card metadata", () => {
const markdown = flowMarkdown.replace("[[SCR-ORDER]]", "SCR-MISSING");
const { resolved } = resolveFlow(markdown);
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.objects.find((entry) => entry.nodeId === "order_screen");
assert.ok(target);
assert.equal(target.previewLinktext, undefined);
assert.equal(target.hoverTitle, "Flow Object");
assert.equal(target.hoverRows.find((row) => row.label === "ref")?.value, "SCR-MISSING");
assert.match(target.nativeTooltip, /ref: SCR-MISSING/);
assert.notEqual(target.hoverText, undefined);
});
test("flow_diagram unresolved object ref wikilink falls back without raw Page Preview target", () => {
const markdown = flowMarkdown.replace("[[SCR-ORDER]]", "[[SCR-MISSING]]");
const { resolved } = resolveFlow(markdown);
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.objects.find((entry) => entry.nodeId === "order_screen");
assert.ok(target);
assert.equal(target.previewLinktext, undefined);
assert.equal(target.filePath, undefined);
assert.equal(target.hoverRows.find((row) => row.label === "ref")?.value, "[[SCR-MISSING]]");
assert.match(target.nativeTooltip, /ref: \[\[SCR-MISSING\]\]/);
});
test("flow_diagram empty object ref has fallback card metadata and safe tooltip", () => {
const { resolved } = resolveFlow();
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.objects.find((entry) => entry.nodeId === "session_store");
assert.ok(target);
assert.equal(target.previewLinktext, undefined);
assert.equal(target.hoverRows.find((row) => row.label === "ref")?.value, undefined);
assert.match(target.nativeTooltip, /Flow Object: session_store/);
assert.match(target.nativeTooltip, /ref: -/);
});
test("flow_diagram flow hover metadata is generated for each Flows row", () => {
const { resolved } = resolveFlow();
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
assert.equal(metadata.flows.length, resolved.edges.length);
assert.deepEqual(metadata.flows.map((target) => target.mermaidId), [
"FLOW_1_FLOW_001",
"FLOW_2_FLOW_002",
"FLOW_3_FLOW_003",
"FLOW_4_FLOW_004"
]);
});
test("flow_diagram flow hover metadata includes row details and raw data ref", () => {
const { resolved } = resolveFlow();
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-001");
assert.ok(target);
assert.equal(target.hoverTitle, "Flow");
assert.deepEqual(
Object.fromEntries(target.hoverRows.map((row) => [row.label, row.value])),
{
id: "FLOW-001",
from: "order_screen",
to: "order_process",
data: "[[DATA-ORDER-REQUEST]]",
notes: "Submit order"
}
);
assert.match(target.hoverText, /data: \[\[DATA-ORDER-REQUEST\]\]/);
});
test("flow_diagram flow hover metadata exposes resolved file path for resolved wikilink data", () => {
const { resolved } = resolveFlow(flowMarkdown, [
{ path: "DATA-ORDER-REQUEST.md", content: "---\ntype: data_object\nid: DATA-ORDER-REQUEST\nname: Order Request\n---\n" }
]);
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-001");
assert.ok(target);
assert.equal(target.previewLinktext, "DATA-ORDER-REQUEST.md");
assert.equal(target.filePath, "DATA-ORDER-REQUEST.md");
assert.notEqual(target.previewLinktext, "[[DATA-ORDER-REQUEST]]");
assert.match(target.nativeTooltip, /Flow: FLOW-001/);
assert.match(target.nativeTooltip, /data: \[\[DATA-ORDER-REQUEST\]\]/);
assert.ok(target.hoverRows.length > 0);
});
test("flow_diagram plain flow data falls back to custom card metadata", () => {
const { resolved } = resolveFlow();
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-002");
assert.ok(target);
assert.equal(target.previewLinktext, undefined);
assert.equal(target.hoverTitle, "Flow");
assert.equal(target.hoverRows.find((row) => row.label === "data")?.value, "Order result");
assert.match(target.nativeTooltip, /data: Order result/);
assert.notEqual(target.hoverText, undefined);
});
test("flow_diagram unresolved flow data wikilink falls back without raw Page Preview target", () => {
const { resolved } = resolveFlow();
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-001");
assert.ok(target);
assert.equal(target.previewLinktext, undefined);
assert.equal(target.filePath, undefined);
assert.equal(target.hoverRows.find((row) => row.label === "data")?.value, "[[DATA-ORDER-REQUEST]]");
assert.match(target.nativeTooltip, /data: \[\[DATA-ORDER-REQUEST\]\]/);
});
test("flow_diagram empty flow data has fallback card metadata and safe tooltip", () => {
const markdown = flowMarkdown.replace("| FLOW-002 | order_process | session_store | Order result | Store result |", "| FLOW-002 | order_process | session_store | | Store result |");
const { resolved } = resolveFlow(markdown);
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-002");
assert.ok(target);
assert.equal(target.previewLinktext, undefined);
assert.equal(target.hoverRows.find((row) => row.label === "data")?.value, undefined);
assert.match(target.nativeTooltip, /Flow: FLOW-002/);
assert.match(target.nativeTooltip, /data: -/);
});
test("flow_diagram hover metadata is not native-tooltip-only", () => {
const { resolved } = resolveFlow();
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
assert.equal(metadata.objects.every((target) => target.hoverRows.length > 0), true);
assert.equal(metadata.flows.every((target) => target.hoverRows.length > 0), true);
assert.equal(metadata.objects.some((target) => target.previewLinktext), false);
});
test("flow_diagram hover metadata uses stable node and flow mapping keys", () => {
const { resolved } = resolveFlow(flowDomainMarkdown);
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
assert.equal(metadata.objects.find((target) => target.nodeId === "ORDER_ENTRY")?.mermaidId, "ORDER_ENTRY");
assert.equal(metadata.objects.find((target) => target.nodeId === "SESSION_STORE")?.mermaidId, "SESSION_STORE");
assert.equal(metadata.flows.find((target) => target.edgeId === "F01")?.mermaidId, "FLOW_1_F01");
assert.equal(metadata.flows.find((target) => target.edgeId === "F05")?.mermaidId, "FLOW_5_F05");
});
test("flow_diagram refs can point to screen, app_process, and data_object without DFD compatibility warnings", () => {
const markdown = flowMarkdown.replace(
"| data_store | Data Store | datastore | | order | Persistent state |",
@ -519,4 +769,4 @@ test("Applied Color Scheme placement policy uses Details for color-aware preview
test("DFD and Flow Applied Color Scheme placement is not Relationships", () => {
assert.notEqual(getAppliedColorSchemeLowerPaneSlot("dfd_diagram"), "impact");
assert.notEqual(getAppliedColorSchemeLowerPaneSlot("flow_diagram"), "impact");
});
});

View file

@ -6,7 +6,7 @@ const outputFile = "dist/test-mermaid-node-interactions.mjs";
await build({
stdin: {
contents: 'export { resolveGraphHoverParent, resolveGraphHoverLinkTargetElement } from "./src/views/mermaid-node-interactions";',
contents: 'export { getGraphInteractionNativeTooltipText, resolveGraphHoverParent, resolveGraphHoverLinkTargetElement } from "./src/views/mermaid-node-interactions";',
resolveDir: ".",
sourcefile: "test-mermaid-node-interactions-entry.ts",
loader: "ts"
@ -33,9 +33,11 @@ await build({
logLevel: "silent"
});
const { resolveGraphHoverParent, resolveGraphHoverLinkTargetElement } = await import(
`../${outputFile}?t=${Date.now()}`
);
const {
getGraphInteractionNativeTooltipText,
resolveGraphHoverParent,
resolveGraphHoverLinkTargetElement
} = await import(`../${outputFile}?t=${Date.now()}`);
class TestElement {
constructor(className = "", parent = null) {
@ -125,3 +127,30 @@ test("resolveGraphHoverLinkTargetElement falls back through viewport and viewer
assert.equal(resolveGraphHoverLinkTargetElement(svgNode, viewerRoot), viewport);
assert.equal(resolveGraphHoverLinkTargetElement(detached, viewerRoot), viewerRoot);
});
test("getGraphInteractionNativeTooltipText keeps normal tooltip fallback without rich metadata", () => {
assert.equal(
getGraphInteractionNativeTooltipText({
mermaidId: "node",
linktext: "MODEL.md",
sourcePath: "SOURCE.md",
label: "Model Node"
}),
"Model Node"
);
});
test("getGraphInteractionNativeTooltipText prefers explicit safe tooltip for rich hover metadata", () => {
assert.equal(
getGraphInteractionNativeTooltipText({
mermaidId: "node",
linktext: "SOURCE.md",
sourcePath: "SOURCE.md",
label: "[[MISSING]]",
hoverRows: [{ label: "ref", value: "[[MISSING]]" }],
nativeTooltip: "Flow Object: NODE\nref: [[MISSING]]"
}),
"Flow Object: NODE\nref: [[MISSING]]"
);
});