Redesign graph viewer navigation
This commit is contained in:
parent
7834461eb9
commit
9bd41c5982
8 changed files with 354 additions and 72 deletions
|
|
@ -23,7 +23,7 @@ approved contract and measured cross-project evidence; it is not an unimplemente
|
||||||
- Result envelope: `schemas/result.schema.json`, version 1.
|
- Result envelope: `schemas/result.schema.json`, version 1.
|
||||||
- Changeset schema: `schemas/changeset.schema.json`, version 1.
|
- Changeset schema: `schemas/changeset.schema.json`, version 1.
|
||||||
- Index schema: version 1, disposable and reproducible.
|
- Index schema: version 1, disposable and reproducible.
|
||||||
- Core, CLI, and MCP server: version 0.8.2.
|
- Core, CLI, and MCP server: version 0.9.0.
|
||||||
|
|
||||||
Schema files describe the generic interchange contract. Runtime validation remains responsible for
|
Schema files describe the generic interchange contract. Runtime validation remains responsible for
|
||||||
path confinement, source hashing, relationship resolution, dependency cycles, project limits, stale
|
path confinement, source hashing, relationship resolution, dependency cycles, project limits, stale
|
||||||
|
|
@ -101,25 +101,35 @@ The HTTP listener binds to `127.0.0.1` on an operating-system-selected port. A c
|
||||||
random token is part of every accepted URL path. Only `GET` and `HEAD` are supported. Responses use
|
random token is part of every accepted URL path. Only `GET` and `HEAD` are supported. Responses use
|
||||||
no-store caching, a restrictive content-security policy, frame denial, MIME sniffing protection,
|
no-store caching, a restrictive content-security policy, frame denial, MIME sniffing protection,
|
||||||
and no-referrer policy. The built-in template uses only same-origin JSON endpoints for graph
|
and no-referrer policy. The built-in template uses only same-origin JSON endpoints for graph
|
||||||
overview, bounded search, exact node content, bounded incoming-and-outgoing neighborhoods, and one
|
overview, bounded search, exact descriptor-category filtering, exact node content, bounded
|
||||||
lease heartbeat. The heartbeat changes no project or index state. There is no write endpoint,
|
incoming-and-outgoing neighborhoods, and one lease heartbeat. Descriptor filtering accepts only
|
||||||
arbitrary query endpoint, static filesystem handler, external asset, or project-selection control.
|
family, authority, status, or tag plus one exact value. The heartbeat changes no project or index
|
||||||
|
state. There is no write endpoint, arbitrary query endpoint, static filesystem handler, external
|
||||||
|
asset, or project-selection control.
|
||||||
|
|
||||||
The `graph-browser@6` template provides mouse-wheel zoom centered on the pointer, left-button drag
|
The `graph-browser@7` template provides mouse-wheel zoom centered on the pointer, left-button drag
|
||||||
pan, explicit zoom-in and zoom-out buttons, a reset-view button, and a live zoom percentage. A
|
pan, explicit zoom-in and zoom-out buttons, a reset-view button, and a live zoom percentage. A
|
||||||
four-pixel drag threshold defers pointer capture and preserves node activation for ordinary clicks.
|
four-pixel drag threshold defers pointer capture and preserves node activation for ordinary clicks.
|
||||||
Loading another root node fits the viewport to the returned neighborhood, including a useful
|
Loading another root node fits the viewport to the returned neighborhood, including a useful
|
||||||
minimum scale for a single-node result. The current root begins selected, and activating another
|
minimum scale for a single-node result. The current root begins selected, and activating another
|
||||||
graph node moves the visible selection ring to it. Space centers the viewport on the selected node
|
graph node moves the visible selection ring to it. Space centers the viewport on the selected node
|
||||||
without changing zoom. Reset restores the fitted neighborhood view. Empty-canvas guidance is hidden
|
without changing zoom. Reset restores the fitted neighborhood view. Empty-canvas guidance is hidden
|
||||||
whenever a neighborhood is rendered.
|
whenever a neighborhood is rendered. The page is fixed to the browser viewport. Search and exact
|
||||||
|
filter results fill the left panel, neighborhood traversal fills the right panel, and only the
|
||||||
|
center SVG canvas pans or zooms.
|
||||||
|
|
||||||
Activating a graph node opens a modal inspector containing that node's complete validated metadata
|
Left-clicking or pressing Enter on a graph node opens a compact descriptor card containing the
|
||||||
and content. Inspection does not replace the current neighborhood or reset the viewport. The modal
|
validated metadata and content previously shown in the details panel. Its family, authority,
|
||||||
supports keyboard activation, Escape, explicit close controls, and backdrop dismissal. Loading the
|
status, and tag pills are buttons that replace the left result list with exact matching nodes.
|
||||||
inspected node as the new root requires the separate Explore neighborhood action. Both side panels
|
Right-clicking or pressing Shift+Enter opens the complete inspector. Inspection does not replace
|
||||||
support pointer and keyboard resizing. The unblurred modal supports native resizing and constrained
|
the current neighborhood or reset the viewport. Both dialogs support Escape, explicit close
|
||||||
title-bar dragging.
|
controls, and backdrop dismissal. Loading the inspected node as the new root requires the separate
|
||||||
|
Explore neighborhood action. Both side panels support pointer and keyboard resizing. The unblurred
|
||||||
|
full inspector supports native resizing and constrained title-bar dragging.
|
||||||
|
|
||||||
|
The header exposes a Nodes/Flow segmented selector. Both positions intentionally retain the node
|
||||||
|
graph in this contract version. Flow traversal and layout semantics remain unclaimed until relation
|
||||||
|
direction and eligibility are defined.
|
||||||
|
|
||||||
The browser derives presentation roles only from the returned bounded graph. The current root is
|
The browser derives presentation roles only from the returned bounded graph. The current root is
|
||||||
the primary focus. Nodes reachable through outgoing edges are children. Remaining incoming and
|
the primary focus. Nodes reachable through outgoing edges are children. Remaining incoming and
|
||||||
|
|
|
||||||
|
|
@ -61,22 +61,25 @@ only through the explicit local CLI integration command.
|
||||||
|
|
||||||
## Visualization boundary
|
## Visualization boundary
|
||||||
|
|
||||||
`docforge_visualize` starts the fixed built-in `graph-browser@6` template against the currently
|
`docforge_visualize` starts the fixed built-in `graph-browser@7` template against the currently
|
||||||
validated derived index. It may focus one stable node, run one bounded lexical query, or open the
|
validated derived index. It may focus one stable node, run one bounded lexical query, or open the
|
||||||
project overview. The tool returns a loopback URL and exact snapshot identity.
|
project overview. The tool returns a loopback URL and exact snapshot identity.
|
||||||
|
|
||||||
The tool cannot select a project, database, template, host, port, filesystem path, or SQL
|
The tool cannot select a project, database, template, host, port, filesystem path, or SQL
|
||||||
expression. Its HTTP surface is token-bound, read-only, same-origin, and limited to overview,
|
expression. Its HTTP surface is token-bound, read-only, same-origin, and limited to overview,
|
||||||
search/filter, node-neighborhood JSON, and a read-only browser-lease heartbeat. The browser exposes
|
search, exact family/authority/status/tag filtering, node-neighborhood JSON, and a read-only
|
||||||
an exact validated index snapshot. It rejects index replacement or alteration and requires another
|
browser-lease heartbeat. The browser exposes an exact validated index snapshot. It rejects index
|
||||||
MCP invocation to refresh.
|
replacement or alteration and requires another MCP invocation to refresh.
|
||||||
Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button
|
Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button
|
||||||
drag pan, explicit zoom buttons, reset, and Space-to-center selection never request or mutate
|
drag pan, explicit zoom buttons, reset, and Space-to-center selection never request or mutate
|
||||||
project data. Graph-node activation visibly selects the node, fetches exact node data from the same
|
project data. Left activation visibly selects the node and opens a compact descriptor card.
|
||||||
bounded read endpoint, and opens a client-side modal inspector. Replacing the current root requires
|
Right-click opens the full inspector. Descriptor-pill activation fills the fixed left panel with an
|
||||||
the modal's explicit Explore neighborhood action. The open browser renews a bounded lease in a
|
exact bounded category result set. The fixed right panel contains neighborhood navigation.
|
||||||
detached local worker, so standard-input transaction completion does not close the listener. The
|
Replacing the current root requires an explicit Explore neighborhood action. The Nodes/Flow
|
||||||
worker tracks the longer-lived MCP client host and closes when that owner exits.
|
selector retains the same graph until a later contract defines flow semantics. The open browser
|
||||||
|
renews a bounded lease in a detached local worker, so standard-input transaction completion does
|
||||||
|
not close the listener. The worker tracks the longer-lived MCP client host and closes when that
|
||||||
|
owner exits.
|
||||||
Explicit service shutdown closes its tracked worker, and abandoned pages expire.
|
Explicit service shutdown closes its tracked worker, and abandoned pages expire.
|
||||||
|
|
||||||
## Excluded tools
|
## Excluded tools
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@ The DocForge repository contains the complete generic CLI and stdio MCP server.
|
||||||
|
|
||||||
- Project identity, validation, exact-node retrieval, lexical search, and filtering.
|
- Project identity, validation, exact-node retrieval, lexical search, and filtering.
|
||||||
- Backlinks, dependency traversal, impact traversal, and bounded context profiles.
|
- Backlinks, dependency traversal, impact traversal, and bounded context profiles.
|
||||||
- `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@6` viewer.
|
- `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@7` viewer.
|
||||||
The viewer supports search, family filtering, exact-node inspection, bounded neighborhoods,
|
The viewer supports search, family filtering, exact-node inspection, bounded neighborhoods,
|
||||||
modal inspection without losing the current neighborhood, explicit neighborhood exploration,
|
exact descriptor-pill filtering, compact left-click descriptors, full right-click inspection,
|
||||||
fitted single- and multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom,
|
fixed search and neighborhood side panels, explicit neighborhood exploration, fitted single- and
|
||||||
left-button drag panning, zoom controls, and viewport reset.
|
multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom, left-button drag
|
||||||
|
panning, zoom controls, viewport reset, and a reserved Nodes/Flow selector.
|
||||||
- Isolated documentation changesets, proposal validation, diffs, and escaped HTML previews when a
|
- Isolated documentation changesets, proposal validation, diffs, and escaped HTML previews when a
|
||||||
proposal writer and render view are configured.
|
proposal writer and render view are configured.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "docforge"
|
name = "docforge"
|
||||||
version = "0.8.2"
|
version = "0.9.0"
|
||||||
description = "Project-scoped documentation indexing and context service"
|
description = "Project-scoped documentation indexing and context service"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from typing import cast
|
||||||
from .errors import DocForgeError
|
from .errors import DocForgeError
|
||||||
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
|
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
|
DEFAULT_EDGE_LIMIT = 100
|
||||||
MAX_EDGE_LIMIT = 400
|
MAX_EDGE_LIMIT = 400
|
||||||
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
|
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
|
||||||
|
|
@ -167,6 +167,42 @@ class VisualizationIndexSnapshot:
|
||||||
snapshot=True,
|
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]:
|
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:
|
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")
|
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
|
||||||
|
|
@ -539,6 +575,8 @@ class VisualizationRunner:
|
||||||
)
|
)
|
||||||
elif parsed.path == f"{prefix}/api/search":
|
elif parsed.path == f"{prefix}/api/search":
|
||||||
payload = self._search(reader, params)
|
payload = self._search(reader, params)
|
||||||
|
elif parsed.path == f"{prefix}/api/filter":
|
||||||
|
payload = self._filter(reader, params)
|
||||||
elif parsed.path == f"{prefix}/api/node":
|
elif parsed.path == f"{prefix}/api/node":
|
||||||
payload = self._node(reader, params)
|
payload = self._node(reader, params)
|
||||||
else:
|
else:
|
||||||
|
|
@ -558,6 +596,7 @@ class VisualizationRunner:
|
||||||
"stale_adapter_source": HTTPStatus.CONFLICT,
|
"stale_adapter_source": HTTPStatus.CONFLICT,
|
||||||
"visualization_stale": HTTPStatus.CONFLICT,
|
"visualization_stale": HTTPStatus.CONFLICT,
|
||||||
"invalid_query": HTTPStatus.BAD_REQUEST,
|
"invalid_query": HTTPStatus.BAD_REQUEST,
|
||||||
|
"invalid_filter": HTTPStatus.BAD_REQUEST,
|
||||||
"invalid_depth": HTTPStatus.BAD_REQUEST,
|
"invalid_depth": HTTPStatus.BAD_REQUEST,
|
||||||
"invalid_limit": HTTPStatus.BAD_REQUEST,
|
"invalid_limit": HTTPStatus.BAD_REQUEST,
|
||||||
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
|
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||||
|
|
@ -607,6 +646,16 @@ class VisualizationRunner:
|
||||||
)
|
)
|
||||||
return reader.node(node_id, depth=depth, limit=limit)
|
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:
|
def _current_reader(self) -> VisualizationIndexSnapshot:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
reader = self._reader
|
reader = self._reader
|
||||||
|
|
@ -864,6 +913,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>DocForge graph</title>
|
<title>DocForge graph</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
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;
|
font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { 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, input, select { font: inherit; }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
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 {
|
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);
|
border-bottom: 1px solid var(--line); background: rgba(8, 18, 30, .96);
|
||||||
}
|
}
|
||||||
header h1 { margin: 0; font-size: 17px; }
|
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); }
|
.stats { display: flex; gap: 14px; color: var(--muted); }
|
||||||
.status { margin-left: auto; color: var(--muted); }
|
.status { margin-left: auto; color: var(--muted); }
|
||||||
.layout {
|
.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);
|
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 {
|
.panel-resizer {
|
||||||
position: relative; z-index: 4; min-height: 0; background: #0a1521;
|
position: relative; z-index: 4; min-height: 0; background: #0a1521;
|
||||||
cursor: col-resize; touch-action: none;
|
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:hover::after, .panel-resizer:focus-visible::after,
|
||||||
.panel-resizer.resizing::after { background: var(--accent); }
|
.panel-resizer.resizing::after { background: var(--accent); }
|
||||||
.panel-resizer:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
.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 {
|
input, select {
|
||||||
width: 100%; border: 1px solid var(--line); border-radius: 8px;
|
width: 100%; border: 1px solid var(--line); border-radius: 8px;
|
||||||
padding: 9px 10px; background: var(--panel-2); color: var(--text);
|
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;
|
border: 1px solid #277fa0; border-radius: 8px; padding: 8px 12px;
|
||||||
background: #12384a; color: var(--text);
|
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 {
|
.section-label {
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
margin: 18px 0 8px; color: var(--muted); font-size: 11px;
|
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;
|
min-width: 24px; border: 1px solid var(--line); border-radius: 999px;
|
||||||
padding: 1px 6px; text-align: center; letter-spacing: 0;
|
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 { display: grid; gap: 6px; }
|
||||||
.node-list-item {
|
.node-list-item {
|
||||||
width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr);
|
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; }
|
.node-list-copy span { color: var(--muted); font-size: 11px; }
|
||||||
.legend {
|
.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 {
|
.legend span {
|
||||||
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
|
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:hover, .result:focus { border-color: var(--accent); }
|
||||||
.result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; }
|
.result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.result span { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
.result span { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||||
.canvas { position: relative; min-height: 0; overflow: hidden; }
|
.canvas { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
|
||||||
svg { width: 100%; height: 100%; min-height: 620px; background:
|
svg { width: 100%; height: 100%; background:
|
||||||
radial-gradient(circle at center, #10243a 0, #07101a 64%);
|
radial-gradient(circle at center, #10243a 0, #07101a 64%);
|
||||||
cursor: grab; touch-action: none; user-select: none;
|
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);
|
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;
|
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 { display: grid; gap: 8px; margin: 14px 0; }
|
||||||
.meta div { display: grid; grid-template-columns: 82px 1fr; gap: 8px; }
|
.meta div { display: grid; grid-template-columns: 82px 1fr; gap: 8px; }
|
||||||
.meta dt { color: var(--muted); }
|
.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 { min-height: 0; overflow: auto; padding: 18px; }
|
||||||
.dialog-body pre { max-height: none; }
|
.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 {
|
.dialog-actions {
|
||||||
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px;
|
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px;
|
||||||
border-top: 1px solid var(--line); background: var(--panel-2);
|
border-top: 1px solid var(--line); background: var(--panel-2);
|
||||||
}
|
}
|
||||||
.error { color: #ff9aac; }
|
.error { color: #ff9aac; }
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.layout { grid-template-columns: 240px 1fr; }
|
:root { --left-width: 240px; --right-width: 260px; }
|
||||||
.panel-resizer { display: none; }
|
.layout {
|
||||||
.right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
|
grid-template-columns: var(--left-width) 7px minmax(280px, 1fr) 7px var(--right-width);
|
||||||
|
}
|
||||||
|
.stats { display: none; }
|
||||||
|
}
|
||||||
|
@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);
|
||||||
}
|
}
|
||||||
@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; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<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>
|
<h1 id="project-title">DocForge graph</h1>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<span><strong id="node-count">—</strong> nodes</span>
|
<span><strong id="node-count">—</strong> nodes</span>
|
||||||
|
|
@ -1124,15 +1234,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
<label for="family">Family</label>
|
<label for="family">Family</label>
|
||||||
<select id="family" name="family"><option value="">All families</option></select>
|
<select id="family" name="family"><option value="">All families</option></select>
|
||||||
</form>
|
</form>
|
||||||
|
<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>
|
<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>
|
|
||||||
</aside>
|
</aside>
|
||||||
<div class="panel-resizer" id="left-resizer" role="separator" tabindex="0"
|
<div class="panel-resizer" id="left-resizer" role="separator" tabindex="0"
|
||||||
aria-label="Resize navigation panel" aria-orientation="vertical"
|
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>
|
<span>The project-bound listener is unavailable. Invoke docforge_visualize again.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewport-hint">
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
|
<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>
|
aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
|
||||||
<aside class="right" aria-label="Node details">
|
<aside class="right" aria-label="Neighborhood navigation">
|
||||||
<div id="details">
|
<div class="neighborhood" id="neighborhood" hidden>
|
||||||
<p class="summary">Choose a search result to load its neighborhood.</p>
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1182,6 +1294,20 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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>
|
<script>
|
||||||
const base = location.pathname.replace(/\/?$/, "/");
|
const base = location.pathname.replace(/\/?$/, "/");
|
||||||
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
|
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
|
||||||
|
|
@ -1189,6 +1315,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
overview: null,
|
overview: null,
|
||||||
graph: null,
|
graph: null,
|
||||||
root: null,
|
root: null,
|
||||||
|
mode: "nodes",
|
||||||
depth: 1,
|
depth: 1,
|
||||||
searchLimit: 1,
|
searchLimit: 1,
|
||||||
viewport: {...defaultViewport},
|
viewport: {...defaultViewport},
|
||||||
|
|
@ -1198,6 +1325,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
pointer: null,
|
pointer: null,
|
||||||
suppressClick: false,
|
suppressClick: false,
|
||||||
inspectedNode: null,
|
inspectedNode: null,
|
||||||
|
cardNode: null,
|
||||||
dialogDrag: null,
|
dialogDrag: null,
|
||||||
leaseTimer: null,
|
leaseTimer: null,
|
||||||
};
|
};
|
||||||
|
|
@ -1361,6 +1489,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
results.append(button);
|
results.append(button);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function setResultsContext(label, filtered = false) {
|
||||||
|
$("results-label").textContent = label;
|
||||||
|
$("clear-result-filter").hidden = !filtered;
|
||||||
|
}
|
||||||
function analyzeTopology(data) {
|
function analyzeTopology(data) {
|
||||||
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
|
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
|
||||||
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
|
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
|
||||||
|
|
@ -1590,21 +1722,27 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
group.addEventListener("click", () => {
|
group.addEventListener("click", () => {
|
||||||
if (!state.suppressClick) {
|
if (!state.suppressClick) {
|
||||||
selectNode(node.node_id);
|
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) => {
|
group.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
selectNode(node.node_id);
|
selectNode(node.node_id);
|
||||||
inspectNode(node.node_id);
|
if (event.shiftKey) inspectNode(node.node_id);
|
||||||
|
else showNodeCard(node.node_id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
nodeLayer.append(group);
|
nodeLayer.append(group);
|
||||||
}
|
}
|
||||||
svg.append(edgeLayer, nodeLayer);
|
svg.append(edgeLayer, nodeLayer);
|
||||||
}
|
}
|
||||||
function renderDetails(details, node, data) {
|
function renderDetails(details, node, data, interactiveBadges = false) {
|
||||||
details.replaceChildren();
|
details.replaceChildren();
|
||||||
const heading = document.createElement("div");
|
const heading = document.createElement("div");
|
||||||
heading.className = "detail-head";
|
heading.className = "detail-head";
|
||||||
|
|
@ -1612,10 +1750,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
title.textContent = node.title;
|
title.textContent = node.title;
|
||||||
heading.append(title);
|
heading.append(title);
|
||||||
const badges = document.createElement("div");
|
const badges = document.createElement("div");
|
||||||
for (const value of [node.family, node.authority, node.status, ...node.tags]) {
|
const descriptors = [
|
||||||
const badge = document.createElement("span");
|
{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.className = "badge";
|
||||||
badge.textContent = value;
|
}
|
||||||
|
badge.textContent = descriptor.value;
|
||||||
badges.append(badge);
|
badges.append(badge);
|
||||||
}
|
}
|
||||||
const summary = document.createElement("p");
|
const summary = document.createElement("p");
|
||||||
|
|
@ -1646,6 +1799,28 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
if (dialog.open) dialog.close();
|
if (dialog.open) dialog.close();
|
||||||
else state.inspectedNode = null;
|
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) {
|
async function inspectNode(nodeId) {
|
||||||
try {
|
try {
|
||||||
selectNode(nodeId);
|
selectNode(nodeId);
|
||||||
|
|
@ -1673,24 +1848,54 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
setStatus("Searching validated index…");
|
setStatus("Searching validated index…");
|
||||||
const data = await api(`search?${params}`);
|
const data = await api(`search?${params}`);
|
||||||
renderResults(data.results || []);
|
renderResults(data.results || []);
|
||||||
|
setResultsContext(
|
||||||
|
$("search").value.trim() || $("family").value
|
||||||
|
? `${data.count} search results`
|
||||||
|
: "All nodes",
|
||||||
|
);
|
||||||
setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`);
|
setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error.message, true);
|
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) {
|
async function loadNode(nodeId) {
|
||||||
try {
|
try {
|
||||||
setStatus(`Loading ${nodeId}…`);
|
setStatus(`Loading ${nodeId}…`);
|
||||||
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
|
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
|
||||||
const data = await api(`node?${params}`);
|
const data = await api(`node?${params}`);
|
||||||
renderGraph(data);
|
renderGraph(data);
|
||||||
renderDetails($("details"), data.node, data);
|
|
||||||
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
|
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
|
||||||
history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`);
|
history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error.message, true);
|
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) {
|
function clamp(value, minimum, maximum) {
|
||||||
return Math.min(maximum, Math.max(minimum, value));
|
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(); });
|
$("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); });
|
||||||
$("family").addEventListener("change", 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-in").addEventListener("click", () => zoomAt(.8));
|
||||||
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
|
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
|
||||||
$("reset-view").addEventListener("click", resetViewport);
|
$("reset-view").addEventListener("click", resetViewport);
|
||||||
$("close-node-dialog").addEventListener("click", closeNodeDialog);
|
$("close-node-dialog").addEventListener("click", closeNodeDialog);
|
||||||
$("dismiss-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("left");
|
||||||
setupPanelResizer("right");
|
setupPanelResizer("right");
|
||||||
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
|
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
|
||||||
|
|
@ -1793,6 +2007,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
closeNodeDialog();
|
closeNodeDialog();
|
||||||
if (nodeId) await loadNode(nodeId);
|
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) => {
|
$("node-dialog").addEventListener("click", (event) => {
|
||||||
if (event.target !== $("node-dialog")) return;
|
if (event.target !== $("node-dialog")) return;
|
||||||
const bounds = $("node-dialog").getBoundingClientRect();
|
const bounds = $("node-dialog").getBoundingClientRect();
|
||||||
|
|
@ -1808,8 +2027,26 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
||||||
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
|
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) => {
|
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;
|
const target = event.target;
|
||||||
if (target instanceof Element
|
if (target instanceof Element
|
||||||
&& target.closest("input, select, textarea, button, [contenteditable='true']")) {
|
&& target.closest("input, select, textarea, button, [contenteditable='true']")) {
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||||
visualization = results[11].structuredContent["visualization"]
|
visualization = results[11].structuredContent["visualization"]
|
||||||
self.assertTrue(visualization["read_only"])
|
self.assertTrue(visualization["read_only"])
|
||||||
self.assertTrue(visualization["project_bound"])
|
self.assertTrue(visualization["project_bound"])
|
||||||
self.assertEqual("graph-browser@6", visualization["template"])
|
self.assertEqual("graph-browser@7", visualization["template"])
|
||||||
self.assertEqual("browser_lease", visualization["lifetime"]["policy"])
|
self.assertEqual("browser_lease", visualization["lifetime"]["policy"])
|
||||||
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
|
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
|
||||||
context = results[8].structuredContent
|
context = results[8].structuredContent
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class VisualizationTests(unittest.TestCase):
|
||||||
overview = snapshot.overview()
|
overview = snapshot.overview()
|
||||||
first = snapshot.node("guide.workflow", depth=2, limit=2)
|
first = snapshot.node("guide.workflow", depth=2, limit=2)
|
||||||
second = snapshot.node("guide.workflow", depth=2, limit=2)
|
second = snapshot.node("guide.workflow", depth=2, limit=2)
|
||||||
|
filtered = snapshot.filter_nodes(category="tag", value="canonical", limit=2)
|
||||||
|
|
||||||
self.assertEqual(3, overview["node_count"])
|
self.assertEqual(3, overview["node_count"])
|
||||||
self.assertEqual(2, overview["edge_count"])
|
self.assertEqual(2, overview["edge_count"])
|
||||||
|
|
@ -54,6 +55,10 @@ class VisualizationTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(first, second)
|
self.assertEqual(first, second)
|
||||||
self.assertEqual("guide.workflow", first["root"])
|
self.assertEqual("guide.workflow", first["root"])
|
||||||
|
self.assertEqual(1, filtered["count"])
|
||||||
|
self.assertEqual(1, filtered["total"])
|
||||||
|
self.assertFalse(filtered["truncated"])
|
||||||
|
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
|
||||||
self.assertLessEqual(len(first["edges"]), 2)
|
self.assertLessEqual(len(first["edges"]), 2)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"guide.workflow",
|
"guide.workflow",
|
||||||
|
|
@ -62,6 +67,8 @@ class VisualizationTests(unittest.TestCase):
|
||||||
|
|
||||||
with self.assertRaisesRegex(DocForgeError, "safety boundary"):
|
with self.assertRaisesRegex(DocForgeError, "safety boundary"):
|
||||||
snapshot.node("guide.workflow", depth=1, limit=401)
|
snapshot.node("guide.workflow", depth=1, limit=401)
|
||||||
|
with self.assertRaisesRegex(DocForgeError, "category is unsupported"):
|
||||||
|
snapshot.filter_nodes(category="relation", value="depends_on", limit=2)
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which("node"), "Node.js is required for embedded script validation")
|
@unittest.skipUnless(shutil.which("node"), "Node.js is required for embedded script validation")
|
||||||
def test_embedded_browser_javascript_is_valid(self) -> None:
|
def test_embedded_browser_javascript_is_valid(self) -> None:
|
||||||
|
|
@ -173,17 +180,25 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
|
||||||
self.assertIn('id="zoom-out"', html)
|
self.assertIn('id="zoom-out"', html)
|
||||||
self.assertIn('id="reset-view"', html)
|
self.assertIn('id="reset-view"', html)
|
||||||
self.assertIn('id="node-dialog"', html)
|
self.assertIn('id="node-dialog"', html)
|
||||||
|
self.assertIn('id="node-card"', html)
|
||||||
self.assertIn('id="explore-node"', html)
|
self.assertIn('id="explore-node"', html)
|
||||||
|
self.assertIn('id="explore-card-node"', html)
|
||||||
self.assertIn('id="left-resizer"', html)
|
self.assertIn('id="left-resizer"', html)
|
||||||
self.assertIn('id="right-resizer"', html)
|
self.assertIn('id="right-resizer"', html)
|
||||||
|
self.assertIn('id="view-nodes"', html)
|
||||||
|
self.assertIn('id="view-flow"', html)
|
||||||
self.assertIn('id="neighborhood-sections"', html)
|
self.assertIn('id="neighborhood-sections"', html)
|
||||||
|
self.assertNotIn('id="details"', html)
|
||||||
self.assertIn(".empty[hidden] { display: none; }", html)
|
self.assertIn(".empty[hidden] { display: none; }", html)
|
||||||
|
self.assertIn("html, body { height: 100%; overflow: hidden; }", html)
|
||||||
self.assertIn("Space centers selection", html)
|
self.assertIn("Space centers selection", html)
|
||||||
self.assertIn("resize: both", html)
|
self.assertIn("resize: both", html)
|
||||||
self.assertNotIn("backdrop-filter", html)
|
self.assertNotIn("backdrop-filter", html)
|
||||||
self.assertIn('addEventListener("wheel"', html)
|
self.assertIn('addEventListener("wheel"', html)
|
||||||
self.assertIn('addEventListener("pointermove"', html)
|
self.assertIn('addEventListener("pointermove"', html)
|
||||||
self.assertIn("inspectNode(node.node_id)", html)
|
self.assertIn("inspectNode(node.node_id)", html)
|
||||||
|
self.assertIn("showNodeCard(node.node_id)", html)
|
||||||
|
self.assertIn('addEventListener("contextmenu"', html)
|
||||||
self.assertIn("dialog.showModal()", html)
|
self.assertIn("dialog.showModal()", html)
|
||||||
self.assertIn("await loadNode(nodeId)", html)
|
self.assertIn("await loadNode(nodeId)", html)
|
||||||
self.assertIn("beginDialogDrag", html)
|
self.assertIn("beginDialogDrag", html)
|
||||||
|
|
@ -194,6 +209,9 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
|
||||||
self.assertIn("distanceShade", html)
|
self.assertIn("distanceShade", html)
|
||||||
self.assertIn("viewportForPositions", html)
|
self.assertIn("viewportForPositions", html)
|
||||||
self.assertIn("centerSelectedNode", html)
|
self.assertIn("centerSelectedNode", html)
|
||||||
|
self.assertIn("filterByDescriptor", html)
|
||||||
|
self.assertIn("api(`filter?${params}`)", html)
|
||||||
|
self.assertIn('setViewMode("flow")', html)
|
||||||
self.assertIn('event.code !== "Space"', html)
|
self.assertIn('event.code !== "Space"', html)
|
||||||
self.assertIn('class: "selection-ring"', html)
|
self.assertIn('class: "selection-ring"', html)
|
||||||
self.assertIn("renewViewerLease", html)
|
self.assertIn("renewViewerLease", html)
|
||||||
|
|
@ -205,7 +223,7 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
|
||||||
)[0]
|
)[0]
|
||||||
self.assertNotIn("setPointerCapture", pointerdown)
|
self.assertNotIn("setPointerCapture", pointerdown)
|
||||||
self.assertIn("setPointerCapture", pointermove)
|
self.assertIn("setPointerCapture", pointermove)
|
||||||
self.assertIn("left-drag pans", html)
|
self.assertIn("right-click full inspector", html)
|
||||||
self.assertIn("default-src 'none'", headers["Content-Security-Policy"])
|
self.assertIn("default-src 'none'", headers["Content-Security-Policy"])
|
||||||
self.assertEqual("no-store", headers["Cache-Control"])
|
self.assertEqual("no-store", headers["Cache-Control"])
|
||||||
self.assertEqual("DENY", headers["X-Frame-Options"])
|
self.assertEqual("DENY", headers["X-Frame-Options"])
|
||||||
|
|
@ -231,6 +249,19 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
|
||||||
self.assertEqual("alpha-docs", search["project_id"])
|
self.assertEqual("alpha-docs", search["project_id"])
|
||||||
self.assertGreaterEqual(search["count"], 1)
|
self.assertGreaterEqual(search["count"], 1)
|
||||||
|
|
||||||
|
filter_query = urllib.parse.urlencode(
|
||||||
|
{"category": "tag", "value": "canonical", "limit": overview["max_results"]}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
f"{base}api/filter?{filter_query}", timeout=2
|
||||||
|
) as response:
|
||||||
|
filtered = json.load(response)
|
||||||
|
self.assertEqual("alpha-docs", filtered["project_id"])
|
||||||
|
self.assertEqual("tag", filtered["category"])
|
||||||
|
self.assertEqual("canonical", filtered["value"])
|
||||||
|
self.assertEqual(1, filtered["total"])
|
||||||
|
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
|
||||||
|
|
||||||
node_query = urllib.parse.urlencode(
|
node_query = urllib.parse.urlencode(
|
||||||
{"id": "guide.workflow", "depth": 1, "limit": 20}
|
{"id": "guide.workflow", "depth": 1, "limit": 20}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -206,7 +206,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docforge"
|
name = "docforge"
|
||||||
version = "0.8.2"
|
version = "0.9.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "markdown-it-py" },
|
{ name = "markdown-it-py" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue