1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Redesign graph viewer navigation

This commit is contained in:
Andraxion 2026-07-24 23:15:57 -04:00
parent 7834461eb9
commit 9bd41c5982
8 changed files with 354 additions and 72 deletions

View file

@ -23,7 +23,7 @@ from typing import cast
from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
VISUALIZATION_TEMPLATE = "graph-browser@6"
VISUALIZATION_TEMPLATE = "graph-browser@7"
DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
@ -167,6 +167,42 @@ class VisualizationIndexSnapshot:
snapshot=True,
)
def filter_nodes(
self,
*,
category: str,
value: str,
limit: int,
) -> dict[str, object]:
bounded = self._bounded_limit(limit)
if category not in {"family", "authority", "status", "tag"}:
raise DocForgeError("invalid_filter", "Descriptor filter category is unsupported")
if not value or len(value) > self.max_query_chars:
raise DocForgeError("invalid_filter", "Descriptor filter value is invalid")
if category == "tag":
clause = "EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)"
else:
clause = f"{category} = ?"
with self._connection() as connection:
total = connection.execute(
f"SELECT COUNT(*) FROM nodes WHERE {clause}",
(value,),
).fetchone()[0]
rows = connection.execute(
f"SELECT * FROM nodes WHERE {clause} ORDER BY node_id LIMIT ?",
(value, bounded),
).fetchall()
results = [_node_dict(row, include_content=False) for row in rows]
return self._result(
category=category,
value=value,
count=len(results),
total=total,
truncated=total > len(results),
results=results,
snapshot=True,
)
def node(self, node_id: str, *, depth: int, limit: int) -> dict[str, object]:
if type(depth) is not int or depth < 1 or depth > self.max_depth:
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
@ -539,6 +575,8 @@ class VisualizationRunner:
)
elif parsed.path == f"{prefix}/api/search":
payload = self._search(reader, params)
elif parsed.path == f"{prefix}/api/filter":
payload = self._filter(reader, params)
elif parsed.path == f"{prefix}/api/node":
payload = self._node(reader, params)
else:
@ -558,6 +596,7 @@ class VisualizationRunner:
"stale_adapter_source": HTTPStatus.CONFLICT,
"visualization_stale": HTTPStatus.CONFLICT,
"invalid_query": HTTPStatus.BAD_REQUEST,
"invalid_filter": HTTPStatus.BAD_REQUEST,
"invalid_depth": HTTPStatus.BAD_REQUEST,
"invalid_limit": HTTPStatus.BAD_REQUEST,
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
@ -607,6 +646,16 @@ class VisualizationRunner:
)
return reader.node(node_id, depth=depth, limit=limit)
def _filter(
self,
reader: VisualizationIndexSnapshot,
params: dict[str, list[str]],
) -> dict[str, object]:
category = _one(params, "category").strip()
value = _one(params, "value").strip()
limit = _integer(_one(params, "limit") or "50")
return reader.filter_nodes(category=category, value=value, limit=limit)
def _current_reader(self) -> VisualizationIndexSnapshot:
with self._lock:
reader = self._reader
@ -864,6 +913,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DocForge graph</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E">
<style>
:root {
color-scheme: dark;
@ -887,23 +937,43 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); }
html, body { height: 100%; overflow: hidden; }
body { margin: 0; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.app { display: grid; grid-template-rows: auto 1fr; min-height: 100vh; }
.app { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100dvh; }
header {
display: flex; gap: 18px; align-items: center; padding: 14px 18px;
display: flex; gap: 14px; align-items: center; padding: 10px 14px;
border-bottom: 1px solid var(--line); background: rgba(8, 18, 30, .96);
}
header h1 { margin: 0; font-size: 17px; }
.view-switch {
position: relative; display: grid; grid-template-columns: repeat(2, 58px);
flex: 0 0 auto; padding: 3px; border: 1px solid var(--line); border-radius: 9px;
background: #08131f; isolation: isolate;
}
.view-switch::before {
content: ""; position: absolute; z-index: -1; top: 3px; left: 3px;
width: 58px; height: calc(100% - 6px); border-radius: 6px;
background: #1b536b; box-shadow: 0 0 14px rgba(81, 215, 255, .18);
transition: transform .18s ease;
}
.view-switch[data-mode="flow"]::before { transform: translateX(58px); }
.view-switch button {
min-height: 30px; border: 0; border-radius: 6px; padding: 4px 8px;
background: transparent; color: var(--muted); font-size: 12px; font-weight: 700;
}
.view-switch button[aria-pressed="true"] { color: var(--text); }
.view-switch button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.stats { display: flex; gap: 14px; color: var(--muted); }
.status { margin-left: auto; color: var(--muted); }
.layout {
min-height: 0; display: grid;
min-height: 0; overflow: hidden; display: grid;
grid-template-columns: var(--left-width) 7px minmax(360px, 1fr) 7px var(--right-width);
}
aside { min-height: 0; overflow: auto; padding: 16px; background: var(--panel); }
aside { min-height: 0; overflow: hidden; padding: 16px; background: var(--panel); }
.left, .right { display: flex; flex-direction: column; }
.panel-resizer {
position: relative; z-index: 4; min-height: 0; background: #0a1521;
cursor: col-resize; touch-action: none;
@ -915,7 +985,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
.panel-resizer:hover::after, .panel-resizer:focus-visible::after,
.panel-resizer.resizing::after { background: var(--accent); }
.panel-resizer:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
form { display: grid; gap: 8px; }
form { display: grid; flex: 0 0 auto; gap: 8px; }
input, select {
width: 100%; border: 1px solid var(--line); border-radius: 8px;
padding: 9px 10px; background: var(--panel-2); color: var(--text);
@ -925,7 +995,22 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
border: 1px solid #277fa0; border-radius: 8px; padding: 8px 12px;
background: #12384a; color: var(--text);
}
.results { display: grid; gap: 7px; margin-top: 14px; }
.results-context {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
min-height: 30px; margin-top: 12px; color: var(--muted); font-size: 11px;
}
.results-context strong {
overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap;
}
#clear-result-filter {
flex: 0 0 auto; border: 1px solid var(--line); border-radius: 6px; padding: 3px 7px;
background: var(--panel-2); color: var(--muted); font-size: 11px;
}
#clear-result-filter[hidden] { display: none; }
.results {
display: grid; align-content: start; gap: 7px; min-height: 0;
margin-top: 8px; overflow: auto; padding-right: 2px;
}
.section-label {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin: 18px 0 8px; color: var(--muted); font-size: 11px;
@ -935,7 +1020,12 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
min-width: 24px; border: 1px solid var(--line); border-radius: 999px;
padding: 1px 6px; text-align: center; letter-spacing: 0;
}
.neighborhood { margin-top: 18px; padding-top: 2px; border-top: 1px solid var(--line); }
.neighborhood {
display: flex; flex: 1; flex-direction: column; min-height: 0;
}
.neighborhood[hidden] { display: none; }
.neighborhood-title { margin: 0; font-size: 15px; }
#neighborhood-sections { min-height: 0; overflow: auto; padding-right: 2px; }
.node-list { display: grid; gap: 6px; }
.node-list-item {
width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr);
@ -957,7 +1047,8 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
.node-list-copy span { color: var(--muted); font-size: 11px; }
.legend {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 14px;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 12px;
padding-bottom: 4px;
}
.legend span {
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
@ -973,8 +1064,8 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
.result:hover, .result:focus { border-color: var(--accent); }
.result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; }
.result span { color: var(--muted); font-size: 12px; white-space: nowrap; }
.canvas { position: relative; min-height: 0; overflow: hidden; }
svg { width: 100%; height: 100%; min-height: 620px; background:
.canvas { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
svg { width: 100%; height: 100%; background:
radial-gradient(circle at center, #10243a 0, #07101a 64%);
cursor: grab; touch-action: none; user-select: none;
}
@ -1046,6 +1137,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
display: inline-block; margin: 4px 5px 0 0; border: 1px solid var(--line);
border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 11px;
}
.badge-button { background: #0d2031; }
.badge-button:hover, .badge-button:focus-visible {
border-color: var(--accent); color: var(--text); outline: none;
}
.meta { display: grid; gap: 8px; margin: 14px 0; }
.meta div { display: grid; grid-template-columns: 82px 1fr; gap: 8px; }
.meta dt { color: var(--muted); }
@ -1083,28 +1178,43 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
.dialog-body { min-height: 0; overflow: auto; padding: 18px; }
.dialog-body pre { max-height: none; }
dialog.compact-dialog {
width: min(520px, calc(100vw - 32px)); height: min(620px, calc(100vh - 32px));
resize: none;
}
.compact-dialog .dialog-head { cursor: default; }
.compact-dialog .dialog-body { padding: 16px; }
.compact-dialog .dialog-body pre { max-height: 230px; }
.dialog-actions {
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px;
border-top: 1px solid var(--line); background: var(--panel-2);
}
.error { color: #ff9aac; }
@media (max-width: 980px) {
.layout { grid-template-columns: 240px 1fr; }
.panel-resizer { display: none; }
.right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
:root { --left-width: 240px; --right-width: 260px; }
.layout {
grid-template-columns: var(--left-width) 7px minmax(280px, 1fr) 7px var(--right-width);
}
.stats { display: none; }
}
@media (max-width: 700px) {
.layout { display: block; }
aside { max-height: none; }
svg { min-height: 520px; }
header { flex-wrap: wrap; }
.status { width: 100%; margin-left: 0; }
@media (max-width: 760px) {
:root { --left-width: 220px; --right-width: 240px; }
header h1 { font-size: 14px; }
.status { display: none; }
.layout {
grid-template-columns: var(--left-width) 5px minmax(240px, 1fr) 5px var(--right-width);
}
}
</style>
</head>
<body>
<div class="app">
<header>
<div class="view-switch" id="view-switch" data-mode="nodes"
role="group" aria-label="Graph view">
<button id="view-nodes" type="button" aria-pressed="true">Nodes</button>
<button id="view-flow" type="button" aria-pressed="false">Flow</button>
</div>
<h1 id="project-title">DocForge graph</h1>
<div class="stats">
<span><strong id="node-count"></strong> nodes</span>
@ -1124,15 +1234,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<label for="family">Family</label>
<select id="family" name="family"><option value="">All families</option></select>
</form>
<div class="results" id="results"></div>
<div class="neighborhood" id="neighborhood" hidden>
<div class="legend" role="group" aria-label="Node role colors">
<span class="legend-primary"><i></i>Primary</span>
<span class="legend-child"><i></i>Children</span>
<span class="legend-edge"><i></i>Edge</span>
</div>
<div id="neighborhood-sections"></div>
<div class="results-context">
<strong id="results-label">All nodes</strong>
<button id="clear-result-filter" type="button" hidden>Clear filter</button>
</div>
<div class="results" id="results"></div>
</aside>
<div class="panel-resizer" id="left-resizer" role="separator" tabindex="0"
aria-label="Resize navigation panel" aria-orientation="vertical"
@ -1155,15 +1261,21 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<span>The project-bound listener is unavailable. Invoke docforge_visualize again.</span>
</div>
<div class="viewport-hint">
Click node to inspect · Space centers selection · mouse wheel zooms · left-drag pans
Left-click descriptor · right-click full inspector · Space centers selection
</div>
</main>
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
aria-label="Resize details panel" aria-orientation="vertical"
aria-label="Resize neighborhood panel" aria-orientation="vertical"
aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
<aside class="right" aria-label="Node details">
<div id="details">
<p class="summary">Choose a search result to load its neighborhood.</p>
<aside class="right" aria-label="Neighborhood navigation">
<div class="neighborhood" id="neighborhood" hidden>
<h2 class="neighborhood-title">Neighborhood</h2>
<div class="legend" role="group" aria-label="Node role colors">
<span class="legend-primary"><i></i>Primary</span>
<span class="legend-child"><i></i>Children</span>
<span class="legend-edge"><i></i>Edge</span>
</div>
<div id="neighborhood-sections"></div>
</div>
</aside>
</div>
@ -1182,6 +1294,20 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
</div>
</div>
</dialog>
<dialog class="compact-dialog" id="node-card" aria-labelledby="node-card-label">
<div class="dialog-shell">
<div class="dialog-head">
<strong id="node-card-label">Node descriptor</strong>
<button class="dialog-close" id="close-node-card" type="button"
aria-label="Close node descriptor">×</button>
</div>
<div class="dialog-body" id="node-card-details"></div>
<div class="dialog-actions">
<button class="button" id="explore-card-node" type="button">Explore neighborhood</button>
<button class="button" id="dismiss-node-card" type="button">Close</button>
</div>
</div>
</dialog>
<script>
const base = location.pathname.replace(/\/?$/, "/");
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
@ -1189,6 +1315,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
overview: null,
graph: null,
root: null,
mode: "nodes",
depth: 1,
searchLimit: 1,
viewport: {...defaultViewport},
@ -1198,6 +1325,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
pointer: null,
suppressClick: false,
inspectedNode: null,
cardNode: null,
dialogDrag: null,
leaseTimer: null,
};
@ -1361,6 +1489,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
results.append(button);
}
}
function setResultsContext(label, filtered = false) {
$("results-label").textContent = label;
$("clear-result-filter").hidden = !filtered;
}
function analyzeTopology(data) {
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
@ -1590,21 +1722,27 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
group.addEventListener("click", () => {
if (!state.suppressClick) {
selectNode(node.node_id);
inspectNode(node.node_id);
showNodeCard(node.node_id);
}
});
group.addEventListener("contextmenu", (event) => {
event.preventDefault();
selectNode(node.node_id);
inspectNode(node.node_id);
});
group.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
selectNode(node.node_id);
inspectNode(node.node_id);
if (event.shiftKey) inspectNode(node.node_id);
else showNodeCard(node.node_id);
}
});
nodeLayer.append(group);
}
svg.append(edgeLayer, nodeLayer);
}
function renderDetails(details, node, data) {
function renderDetails(details, node, data, interactiveBadges = false) {
details.replaceChildren();
const heading = document.createElement("div");
heading.className = "detail-head";
@ -1612,10 +1750,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
title.textContent = node.title;
heading.append(title);
const badges = document.createElement("div");
for (const value of [node.family, node.authority, node.status, ...node.tags]) {
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = value;
const descriptors = [
{category: "family", value: node.family},
{category: "authority", value: node.authority},
{category: "status", value: node.status},
...node.tags.map((value) => ({category: "tag", value})),
];
for (const descriptor of descriptors) {
const badge = document.createElement(interactiveBadges ? "button" : "span");
if (interactiveBadges) {
badge.type = "button";
badge.className = "badge badge-button";
badge.title = `Show nodes with ${descriptor.category} ${descriptor.value}`;
badge.addEventListener("click", () => {
filterByDescriptor(descriptor.category, descriptor.value);
});
} else {
badge.className = "badge";
}
badge.textContent = descriptor.value;
badges.append(badge);
}
const summary = document.createElement("p");
@ -1646,6 +1799,28 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
if (dialog.open) dialog.close();
else state.inspectedNode = null;
}
function closeNodeCard() {
const dialog = $("node-card");
if (dialog.open) dialog.close();
else state.cardNode = null;
}
async function showNodeCard(nodeId) {
try {
selectNode(nodeId);
setStatus(`Loading descriptor for ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
state.cardNode = nodeId;
renderDetails($("node-card-details"), data.node, data, true);
$("node-card-label").textContent = short(data.node.title, 72);
const dialog = $("node-card");
if (!dialog.open) dialog.showModal();
$("close-node-card").focus();
setStatus(`Selected ${nodeId}`);
} catch (error) {
setStatus(error.message, true);
}
}
async function inspectNode(nodeId) {
try {
selectNode(nodeId);
@ -1673,24 +1848,54 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus("Searching validated index…");
const data = await api(`search?${params}`);
renderResults(data.results || []);
setResultsContext(
$("search").value.trim() || $("family").value
? `${data.count} search results`
: "All nodes",
);
setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`);
} catch (error) {
setStatus(error.message, true);
}
}
async function filterByDescriptor(category, value) {
const params = new URLSearchParams({
category,
value,
limit: String(state.searchLimit),
});
try {
closeNodeCard();
setStatus(`Filtering ${category} ${value}`);
const data = await api(`filter?${params}`);
$("search").value = "";
$("family").value = category === "family" ? value : "";
renderResults(data.results || []);
setResultsContext(`${category}: ${value} (${data.total})`, true);
const suffix = data.truncated ? ` · showing first ${data.count}` : "";
setStatus(`${data.total} nodes assigned ${category} ${value}${suffix}`);
} catch (error) {
setStatus(error.message, true);
}
}
async function loadNode(nodeId) {
try {
setStatus(`Loading ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
renderGraph(data);
renderDetails($("details"), data.node, data);
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`);
} catch (error) {
setStatus(error.message, true);
}
}
function setViewMode(mode) {
state.mode = mode;
$("view-switch").dataset.mode = mode;
$("view-nodes").setAttribute("aria-pressed", String(mode === "nodes"));
$("view-flow").setAttribute("aria-pressed", String(mode === "flow"));
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
@ -1777,11 +1982,20 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
$("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); });
$("family").addEventListener("change", search);
$("clear-result-filter").addEventListener("click", () => {
$("search").value = "";
$("family").value = "";
search();
});
$("view-nodes").addEventListener("click", () => setViewMode("nodes"));
$("view-flow").addEventListener("click", () => setViewMode("flow"));
$("zoom-in").addEventListener("click", () => zoomAt(.8));
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
$("reset-view").addEventListener("click", resetViewport);
$("close-node-dialog").addEventListener("click", closeNodeDialog);
$("dismiss-node-dialog").addEventListener("click", closeNodeDialog);
$("close-node-card").addEventListener("click", closeNodeCard);
$("dismiss-node-card").addEventListener("click", closeNodeCard);
setupPanelResizer("left");
setupPanelResizer("right");
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
@ -1793,6 +2007,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
closeNodeDialog();
if (nodeId) await loadNode(nodeId);
});
$("explore-card-node").addEventListener("click", async () => {
const nodeId = state.cardNode;
closeNodeCard();
if (nodeId) await loadNode(nodeId);
});
$("node-dialog").addEventListener("click", (event) => {
if (event.target !== $("node-dialog")) return;
const bounds = $("node-dialog").getBoundingClientRect();
@ -1808,8 +2027,26 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
}
});
$("node-card").addEventListener("click", (event) => {
if (event.target !== $("node-card")) return;
const bounds = $("node-card").getBoundingClientRect();
const inside = event.clientX >= bounds.left && event.clientX <= bounds.right
&& event.clientY >= bounds.top && event.clientY <= bounds.bottom;
if (!inside) closeNodeCard();
});
$("node-card").addEventListener("close", () => {
state.cardNode = null;
if (state.graph) {
const nodeCount = state.graph.nodes.length;
const edgeCount = state.graph.edges.length;
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
}
});
document.addEventListener("keydown", (event) => {
if (event.code !== "Space" || event.defaultPrevented || $("node-dialog").open) return;
if (event.code !== "Space" || event.defaultPrevented
|| $("node-dialog").open || $("node-card").open) {
return;
}
const target = event.target;
if (target instanceof Element
&& target.closest("input, select, textarea, button, [contenteditable='true']")) {