bootstrap datagen and graphgen
This commit is contained in:
@@ -519,6 +519,18 @@ try:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"Warning: Could not load tester router: {e}")
|
print(f"Warning: Could not load tester router: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from station.tools.graphgen.api import router as graphgen_router
|
||||||
|
app.include_router(graphgen_router, prefix="/station")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Warning: Could not load graphgen router: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from station.tools.datagen.api import router as datagen_router
|
||||||
|
app.include_router(datagen_router, prefix="/station")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Warning: Could not load datagen router: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/station/{path:path}")
|
@app.get("/station/{path:path}")
|
||||||
def station_route(path: str):
|
def station_route(path: str):
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
"""Datagen - Test data generator for Amar domain models."""
|
"""Datagen — test data generator. Room generators extend BaseDataGenerator."""
|
||||||
|
|
||||||
|
from .base import BaseDataGenerator
|
||||||
|
|
||||||
|
__all__ = ["BaseDataGenerator"]
|
||||||
|
|||||||
180
soleprint/station/tools/datagen/api.py
Normal file
180
soleprint/station/tools/datagen/api.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""FastAPI router for datagen — test data generator."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/tools/datagen", tags=["datagen"])
|
||||||
|
|
||||||
|
# soleprint/ root
|
||||||
|
SPR_ROOT = Path(__file__).parents[3]
|
||||||
|
|
||||||
|
# Loaded generators: name → instance
|
||||||
|
_generators: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────
|
||||||
|
# Generator loading
|
||||||
|
# ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_generators() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Scan station/tools/datagen/ for generator classes and instantiate them.
|
||||||
|
|
||||||
|
A generator file should define a class whose name ends in "Generator".
|
||||||
|
"""
|
||||||
|
datagen_dir = SPR_ROOT / "station" / "tools" / "datagen"
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for py_file in sorted(datagen_dir.glob("*.py")):
|
||||||
|
if py_file.name.startswith("_") or py_file.stem in ("base", "api"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
|
||||||
|
if not spec or not spec.loader:
|
||||||
|
continue
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
# Find generator class (ends with Generator, not the base)
|
||||||
|
for name, obj in vars(mod).items():
|
||||||
|
if (
|
||||||
|
isinstance(obj, type)
|
||||||
|
and name.endswith("Generator")
|
||||||
|
and name != "BaseDataGenerator"
|
||||||
|
):
|
||||||
|
result[py_file.stem] = obj()
|
||||||
|
log.info(f"datagen: loaded {name} from {py_file.name}")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"datagen: could not load {py_file.name}: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_generators() -> dict[str, Any]:
|
||||||
|
"""Return cached generators, loading on first call."""
|
||||||
|
if not _generators:
|
||||||
|
_generators.update(_load_generators())
|
||||||
|
return _generators
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_generator(name: str | None) -> tuple[str, Any]:
|
||||||
|
"""
|
||||||
|
Pick a generator by name or use the only available one.
|
||||||
|
|
||||||
|
Returns (name, instance). Raises HTTPException if not found.
|
||||||
|
"""
|
||||||
|
gens = _get_generators()
|
||||||
|
if not gens:
|
||||||
|
raise HTTPException(status_code=503, detail="No generators loaded")
|
||||||
|
|
||||||
|
if name:
|
||||||
|
if name not in gens:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Generator '{name}' not found. Available: {list(gens)}"
|
||||||
|
)
|
||||||
|
return name, gens[name]
|
||||||
|
|
||||||
|
# Default: first (usually only) generator
|
||||||
|
key = next(iter(gens))
|
||||||
|
return key, gens[key]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────
|
||||||
|
# Pydantic models
|
||||||
|
# ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
model: str
|
||||||
|
count: int = 1
|
||||||
|
generator: str | None = None
|
||||||
|
kwargs: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────
|
||||||
|
# Routes
|
||||||
|
# ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
def index():
|
||||||
|
html = Path(__file__).parent / "templates" / "index.html"
|
||||||
|
return HTMLResponse(html.read_text() if html.exists() else "<h1>datagen</h1>")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "tool": "datagen"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/generators")
|
||||||
|
def list_generators():
|
||||||
|
"""List loaded generator files."""
|
||||||
|
gens = _get_generators()
|
||||||
|
return {
|
||||||
|
"generators": [
|
||||||
|
{
|
||||||
|
"name": k,
|
||||||
|
"models": v.available_models() if hasattr(v, "available_models") else [],
|
||||||
|
}
|
||||||
|
for k, v in gens.items()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/models")
|
||||||
|
def list_models(generator: str | None = None):
|
||||||
|
"""List available model generators."""
|
||||||
|
_, gen = _pick_generator(generator)
|
||||||
|
if hasattr(gen, "available_models"):
|
||||||
|
models = gen.available_models()
|
||||||
|
else:
|
||||||
|
models = [
|
||||||
|
n for n in dir(gen)
|
||||||
|
if not n.startswith("_") and callable(getattr(gen, n))
|
||||||
|
]
|
||||||
|
return {"models": models}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/generate")
|
||||||
|
def generate(req: GenerateRequest):
|
||||||
|
"""Generate test data for a model."""
|
||||||
|
gen_name, gen = _pick_generator(req.generator)
|
||||||
|
try:
|
||||||
|
if hasattr(gen, "generate"):
|
||||||
|
items = gen.generate(req.model, req.count, **req.kwargs)
|
||||||
|
else:
|
||||||
|
method = getattr(gen, req.model.lower(), None)
|
||||||
|
if not method:
|
||||||
|
raise ValueError(f"No method '{req.model.lower()}' on generator")
|
||||||
|
items = [method(**req.kwargs) for _ in range(req.count)]
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"datagen: generate error")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generator": gen_name,
|
||||||
|
"model": req.model,
|
||||||
|
"count": len(items),
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/schema")
|
||||||
|
def get_schema(generator: str | None = None):
|
||||||
|
"""Return graphgen-compatible schema from the generator (if it provides one)."""
|
||||||
|
_, gen = _pick_generator(generator)
|
||||||
|
if hasattr(gen, "schema"):
|
||||||
|
s = gen.schema()
|
||||||
|
if s:
|
||||||
|
return s
|
||||||
|
return {"models": {}, "note": "This generator does not expose a schema."}
|
||||||
80
soleprint/station/tools/datagen/base.py
Normal file
80
soleprint/station/tools/datagen/base.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
BaseDataGenerator — base class for room-specific data generators.
|
||||||
|
|
||||||
|
Room generators extend this class and define methods named after their models:
|
||||||
|
|
||||||
|
class MyGenerator(BaseDataGenerator):
|
||||||
|
def user(self, **kwargs):
|
||||||
|
return {"id": uuid4(), "name": fake.name(), **kwargs}
|
||||||
|
|
||||||
|
def product(self, category=None, **kwargs):
|
||||||
|
return {"id": uuid4(), "name": fake.word(), "category": category, **kwargs}
|
||||||
|
|
||||||
|
The base class provides:
|
||||||
|
- generate(model, count, **kwargs) — call the right method N times
|
||||||
|
- available_models() — list of method names (= model names)
|
||||||
|
- schema() — override to return graphgen-compatible schema
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BaseDataGenerator:
|
||||||
|
"""Base class for room-specific data generators."""
|
||||||
|
|
||||||
|
# Attribute names that are NOT model generators
|
||||||
|
_RESERVED = frozenset({"generate", "available_models", "schema"})
|
||||||
|
|
||||||
|
def generate(self, model: str, count: int = 1, **kwargs) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Generate `count` instances of the given model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: Model name (case-insensitive, matches method name).
|
||||||
|
count: Number of items to generate.
|
||||||
|
**kwargs: Passed through to the model method on every call.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of generated dicts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError if no generator exists for the model.
|
||||||
|
"""
|
||||||
|
method = getattr(self, model.lower(), None)
|
||||||
|
if method is None or not callable(method):
|
||||||
|
available = self.available_models()
|
||||||
|
raise ValueError(
|
||||||
|
f"No generator for '{model}'. "
|
||||||
|
f"Available: {available if available else '(none)'}"
|
||||||
|
)
|
||||||
|
return [method(**kwargs) for _ in range(count)]
|
||||||
|
|
||||||
|
def available_models(self) -> list[str]:
|
||||||
|
"""Return sorted list of model names this generator supports."""
|
||||||
|
return sorted(
|
||||||
|
name for name in dir(self)
|
||||||
|
if not name.startswith("_")
|
||||||
|
and name not in self._RESERVED
|
||||||
|
and callable(getattr(self, name))
|
||||||
|
)
|
||||||
|
|
||||||
|
def schema(self) -> dict | None:
|
||||||
|
"""
|
||||||
|
Optional: return a graphgen-compatible schema dict.
|
||||||
|
|
||||||
|
Override to integrate with graphgen's /api/schema — datagen will
|
||||||
|
surface this via /tools/datagen/api/schema.
|
||||||
|
|
||||||
|
Expected format:
|
||||||
|
{
|
||||||
|
"models": {
|
||||||
|
"User": {
|
||||||
|
"doc": "...",
|
||||||
|
"fields": {
|
||||||
|
"id": {"type": "UUID", "pk": true},
|
||||||
|
"email": {"type": "str"},
|
||||||
|
"org_id": {"type": "FK:Organization"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
return None
|
||||||
497
soleprint/station/tools/datagen/templates/index.html
Normal file
497
soleprint/station/tools/datagen/templates/index.html
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>datagen — Test Data Generator</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #1a1a1a;
|
||||||
|
--border: #333;
|
||||||
|
--text: #e5e5e5;
|
||||||
|
--muted: #a3a3a3;
|
||||||
|
--dim: #666;
|
||||||
|
--amber: #d4a574;
|
||||||
|
--amber-dim: #b8956a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar ── */
|
||||||
|
#topbar {
|
||||||
|
height: 44px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar h1 { font-size: 14px; font-weight: 600; color: var(--amber); letter-spacing: .05em; }
|
||||||
|
#topbar-info { color: var(--dim); font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── Main ── */
|
||||||
|
#main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Left panel: controls ── */
|
||||||
|
#controls {
|
||||||
|
width: 280px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-section {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input[type="number"], input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
select:focus, input:focus { border-color: var(--amber); }
|
||||||
|
select option { background: var(--surface); }
|
||||||
|
|
||||||
|
.count-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-row input { flex: 1; }
|
||||||
|
|
||||||
|
.quick-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 5px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color .12s, color .12s;
|
||||||
|
}
|
||||||
|
.quick-btn:hover { border-color: var(--amber); color: var(--amber); }
|
||||||
|
.quick-btn.active { border-color: var(--amber); color: var(--amber); background: #d4a57411; }
|
||||||
|
|
||||||
|
/* kwargs editor */
|
||||||
|
#kwargs-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
.kwarg-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kwarg-row input {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kwarg-key { width: 90px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.kwarg-val { flex: 1; }
|
||||||
|
|
||||||
|
.kwarg-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--dim);
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.kwarg-remove:hover { border-color: #b91c1c; color: #fca5a5; }
|
||||||
|
|
||||||
|
.add-kwarg-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--dim);
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
transition: border-color .12s, color .12s;
|
||||||
|
}
|
||||||
|
.add-kwarg-btn:hover { border-color: var(--amber); color: var(--amber); }
|
||||||
|
|
||||||
|
/* Generate button */
|
||||||
|
#generate-btn {
|
||||||
|
margin: 14px 16px;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
padding: 9px;
|
||||||
|
background: linear-gradient(135deg, var(--amber), var(--amber-dim));
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #0a0a0a;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
#generate-btn:hover { opacity: .9; }
|
||||||
|
#generate-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Right panel: output ── */
|
||||||
|
#output-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#output-header {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#output-meta { color: var(--dim); font-size: 12px; flex: 1; }
|
||||||
|
|
||||||
|
#copy-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: border-color .12s, color .12s;
|
||||||
|
}
|
||||||
|
#copy-btn:hover { border-color: var(--amber); color: var(--amber); }
|
||||||
|
#copy-btn.copied { border-color: #15803d; color: #86efac; }
|
||||||
|
|
||||||
|
#output-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#output-json {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syntax-ish coloring */
|
||||||
|
.j-key { color: #93c5fd; }
|
||||||
|
.j-str { color: #86efac; }
|
||||||
|
.j-num { color: #fca5a5; }
|
||||||
|
.j-bool { color: var(--amber); }
|
||||||
|
.j-null { color: var(--dim); }
|
||||||
|
.j-punct { color: var(--dim); }
|
||||||
|
|
||||||
|
#placeholder {
|
||||||
|
color: var(--dim);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 80px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#placeholder .hint {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#error-msg {
|
||||||
|
color: #fca5a5;
|
||||||
|
background: #b91c1c18;
|
||||||
|
border: 1px solid #b91c1c44;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="topbar">
|
||||||
|
<h1>datagen</h1>
|
||||||
|
<span>—</span>
|
||||||
|
<span id="topbar-info">loading…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main">
|
||||||
|
<div id="controls">
|
||||||
|
<!-- Model select -->
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Model</div>
|
||||||
|
<select id="model-select">
|
||||||
|
<option value="">— select a model —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Count -->
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Count</div>
|
||||||
|
<div class="count-row">
|
||||||
|
<input type="number" id="count-input" value="1" min="1" max="100" />
|
||||||
|
</div>
|
||||||
|
<div class="quick-btns">
|
||||||
|
<div class="quick-btn" onclick="setCount(1)">1</div>
|
||||||
|
<div class="quick-btn" onclick="setCount(5)">5</div>
|
||||||
|
<div class="quick-btn" onclick="setCount(10)">10</div>
|
||||||
|
<div class="quick-btn" onclick="setCount(25)">25</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- kwargs -->
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Override fields (optional)</div>
|
||||||
|
<div id="kwargs-list"></div>
|
||||||
|
<button class="add-kwarg-btn" onclick="addKwarg()">+ add field override</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="generate-btn" onclick="generate()">Generate</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output -->
|
||||||
|
<div id="output-panel">
|
||||||
|
<div id="output-header">
|
||||||
|
<span id="output-meta">—</span>
|
||||||
|
<button id="copy-btn" onclick="copyOutput()">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div id="error-msg"></div>
|
||||||
|
<div id="output-area">
|
||||||
|
<div id="placeholder">
|
||||||
|
Select a model and click Generate
|
||||||
|
<div class="hint">Results appear here as formatted JSON</div>
|
||||||
|
</div>
|
||||||
|
<pre id="output-json" style="display:none"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentOutput = null;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Boot
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/station/tools/datagen/api/generators');
|
||||||
|
const data = await r.json();
|
||||||
|
const gens = data.generators || [];
|
||||||
|
|
||||||
|
if (!gens.length) {
|
||||||
|
document.getElementById('topbar-info').textContent = 'no generators loaded';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate model select from first generator
|
||||||
|
const models = gens[0].models || [];
|
||||||
|
const sel = document.getElementById('model-select');
|
||||||
|
models.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m;
|
||||||
|
opt.textContent = m;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const name = gens[0].name;
|
||||||
|
document.getElementById('topbar-info').textContent =
|
||||||
|
`${name} · ${models.length} model${models.length !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('topbar-info').textContent = 'error loading generators';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// UI helpers
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function setCount(n) {
|
||||||
|
document.getElementById('count-input').value = n;
|
||||||
|
document.querySelectorAll('.quick-btn').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.textContent === String(n));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKwarg() {
|
||||||
|
const list = document.getElementById('kwargs-list');
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'kwarg-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input class="kwarg-key" type="text" placeholder="field" />
|
||||||
|
<input class="kwarg-val" type="text" placeholder="value" />
|
||||||
|
<button class="kwarg-remove" onclick="this.parentElement.remove()">×</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(row);
|
||||||
|
row.querySelector('.kwarg-key').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKwargs() {
|
||||||
|
const kwargs = {};
|
||||||
|
document.querySelectorAll('.kwarg-row').forEach(row => {
|
||||||
|
const k = row.querySelector('.kwarg-key').value.trim();
|
||||||
|
const v = row.querySelector('.kwarg-val').value.trim();
|
||||||
|
if (k) {
|
||||||
|
// Try to parse as JSON (numbers, booleans, arrays, objects)
|
||||||
|
try { kwargs[k] = JSON.parse(v); }
|
||||||
|
catch { kwargs[k] = v; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return kwargs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Generate
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async function generate() {
|
||||||
|
const model = document.getElementById('model-select').value;
|
||||||
|
if (!model) { showError('Select a model first'); return; }
|
||||||
|
|
||||||
|
const count = parseInt(document.getElementById('count-input').value) || 1;
|
||||||
|
const kwargs = getKwargs();
|
||||||
|
|
||||||
|
hideError();
|
||||||
|
const btn = document.getElementById('generate-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Generating…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/station/tools/datagen/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model, count, kwargs }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
showError(data.detail || 'Generation failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOutput = data;
|
||||||
|
renderOutput(data);
|
||||||
|
} catch (e) {
|
||||||
|
showError('Request failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Generate';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutput(data) {
|
||||||
|
const { model, count, items } = data;
|
||||||
|
document.getElementById('output-meta').textContent =
|
||||||
|
`${count} ${model}${count !== 1 ? 's' : ''} generated`;
|
||||||
|
|
||||||
|
const jsonStr = JSON.stringify(items, null, 2);
|
||||||
|
const highlighted = syntaxHighlight(jsonStr);
|
||||||
|
|
||||||
|
document.getElementById('placeholder').style.display = 'none';
|
||||||
|
const pre = document.getElementById('output-json');
|
||||||
|
pre.style.display = 'block';
|
||||||
|
pre.innerHTML = highlighted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Syntax highlight (minimal)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function syntaxHighlight(json) {
|
||||||
|
return json
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(
|
||||||
|
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
|
||||||
|
match => {
|
||||||
|
if (/^"/.test(match)) {
|
||||||
|
if (/:$/.test(match)) return `<span class="j-key">${match}</span>`;
|
||||||
|
return `<span class="j-str">${match}</span>`;
|
||||||
|
}
|
||||||
|
if (/true|false/.test(match)) return `<span class="j-bool">${match}</span>`;
|
||||||
|
if (/null/.test(match)) return `<span class="j-null">${match}</span>`;
|
||||||
|
return `<span class="j-num">${match}</span>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Copy
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async function copyOutput() {
|
||||||
|
if (!currentOutput) return;
|
||||||
|
const text = JSON.stringify(currentOutput.items, null, 2);
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
const btn = document.getElementById('copy-btn');
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.classList.add('copied');
|
||||||
|
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Error
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function showError(msg) {
|
||||||
|
const el = document.getElementById('error-msg');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideError() {
|
||||||
|
document.getElementById('error-msg').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Keyboard shortcut
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') generate();
|
||||||
|
});
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
soleprint/station/tools/graphgen/__init__.py
Normal file
1
soleprint/station/tools/graphgen/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Graphgen — interactive DB schema visualization."""
|
||||||
50
soleprint/station/tools/graphgen/api.py
Normal file
50
soleprint/station/tools/graphgen/api.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""FastAPI router for graphgen — schema graph explorer."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from .schema import load_graph_schema
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/tools/graphgen", tags=["graphgen"])
|
||||||
|
|
||||||
|
# soleprint/ root (this file: soleprint/station/tools/graphgen/api.py)
|
||||||
|
SPR_ROOT = Path(__file__).parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_paths() -> list[Path]:
|
||||||
|
"""Return ordered list of directories to search for schema."""
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
# cfg/<room>/station/tools/graphgen/ (room-specific, highest priority)
|
||||||
|
cfg_dir = SPR_ROOT / "cfg"
|
||||||
|
if cfg_dir.exists():
|
||||||
|
for room in sorted(cfg_dir.iterdir()):
|
||||||
|
if room.is_dir() and not room.name.startswith(("_", ".")):
|
||||||
|
paths.append(room / "station" / "tools" / "graphgen")
|
||||||
|
|
||||||
|
# station/tools/modelgen/ (shared modelgen schema/)
|
||||||
|
paths.append(SPR_ROOT / "station" / "tools" / "modelgen")
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
def index():
|
||||||
|
"""Serve the graph viewer UI."""
|
||||||
|
html = Path(__file__).parent / "templates" / "index.html"
|
||||||
|
return HTMLResponse(html.read_text() if html.exists() else "<h1>graphgen</h1>")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "tool": "graphgen"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/schema")
|
||||||
|
def get_schema():
|
||||||
|
"""Return graph-ready schema: {models, relationships, source}."""
|
||||||
|
return load_graph_schema(_schema_paths())
|
||||||
174
soleprint/station/tools/graphgen/schema.py
Normal file
174
soleprint/station/tools/graphgen/schema.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Schema reader for graphgen.
|
||||||
|
|
||||||
|
Loads schema from multiple sources (priority order):
|
||||||
|
1. schema.json in a cfg/*/station/tools/graphgen/ directory
|
||||||
|
2. modelgen schema/ folder (Python dataclasses)
|
||||||
|
|
||||||
|
Output format:
|
||||||
|
{
|
||||||
|
"models": [{"id", "name", "doc", "fields": [{"name", "type", "pk", "fk", "m2m", "nullable"}]}],
|
||||||
|
"relationships": [{"from_model", "from_field", "to_model", "type"}],
|
||||||
|
"source": "path or description"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def load_graph_schema(search_paths: list[Path]) -> dict:
|
||||||
|
"""Search for schema in given paths and return graph-ready data."""
|
||||||
|
for base in search_paths:
|
||||||
|
if not base.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# schema.json (JSON format)
|
||||||
|
json_path = base / "schema.json"
|
||||||
|
if json_path.exists():
|
||||||
|
log.info(f"graphgen: loading schema from {json_path}")
|
||||||
|
return _load_json_schema(json_path)
|
||||||
|
|
||||||
|
# modelgen schema/ folder
|
||||||
|
py_schema = base / "schema"
|
||||||
|
if (py_schema / "__init__.py").exists():
|
||||||
|
log.info(f"graphgen: loading Python schema from {py_schema}")
|
||||||
|
return _load_py_schema(py_schema)
|
||||||
|
|
||||||
|
return {"models": [], "relationships": [], "source": "empty"}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_schema(path: Path) -> dict:
|
||||||
|
"""Load from schema.json — simple declarative format."""
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"graphgen: could not parse {path}: {e}")
|
||||||
|
return {"models": [], "relationships": [], "source": str(path), "error": str(e)}
|
||||||
|
|
||||||
|
models = []
|
||||||
|
relationships = []
|
||||||
|
|
||||||
|
for model_name, model_data in data.get("models", {}).items():
|
||||||
|
fields = []
|
||||||
|
for field_name, field_data in model_data.get("fields", {}).items():
|
||||||
|
field_type = field_data.get("type", "str")
|
||||||
|
fk_target = None
|
||||||
|
m2m = False
|
||||||
|
|
||||||
|
if field_type.startswith("FK:"):
|
||||||
|
fk_target = field_type[3:]
|
||||||
|
field_type = "FK"
|
||||||
|
relationships.append({
|
||||||
|
"from_model": model_name,
|
||||||
|
"from_field": field_name,
|
||||||
|
"to_model": fk_target,
|
||||||
|
"type": "FK",
|
||||||
|
})
|
||||||
|
elif field_type.startswith("M2M:"):
|
||||||
|
fk_target = field_type[4:]
|
||||||
|
field_type = "M2M"
|
||||||
|
m2m = True
|
||||||
|
relationships.append({
|
||||||
|
"from_model": model_name,
|
||||||
|
"from_field": field_name,
|
||||||
|
"to_model": fk_target,
|
||||||
|
"type": "M2M",
|
||||||
|
})
|
||||||
|
|
||||||
|
fields.append({
|
||||||
|
"name": field_name,
|
||||||
|
"type": field_type,
|
||||||
|
"pk": field_data.get("pk", False),
|
||||||
|
"fk": fk_target,
|
||||||
|
"m2m": m2m,
|
||||||
|
"nullable": field_data.get("nullable", False),
|
||||||
|
})
|
||||||
|
|
||||||
|
models.append({
|
||||||
|
"id": model_name,
|
||||||
|
"name": model_name,
|
||||||
|
"doc": model_data.get("doc", ""),
|
||||||
|
"fields": fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"models": models,
|
||||||
|
"relationships": relationships,
|
||||||
|
"source": str(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_py_schema(schema_dir: Path) -> dict:
|
||||||
|
"""Load from modelgen Python dataclasses schema/ folder."""
|
||||||
|
try:
|
||||||
|
from ..modelgen.loader.schema import SchemaLoader
|
||||||
|
|
||||||
|
loader = SchemaLoader(schema_dir).load()
|
||||||
|
return _convert_modelgen(loader, str(schema_dir))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"graphgen: Python schema load failed: {e}")
|
||||||
|
return {
|
||||||
|
"models": [],
|
||||||
|
"relationships": [],
|
||||||
|
"source": str(schema_dir),
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_modelgen(loader: Any, source: str) -> dict:
|
||||||
|
"""Convert modelgen SchemaLoader output to graph data."""
|
||||||
|
all_names = {m.name for m in loader.models}
|
||||||
|
models = []
|
||||||
|
relationships = []
|
||||||
|
|
||||||
|
for model_def in loader.models:
|
||||||
|
fields = []
|
||||||
|
for field in model_def.fields:
|
||||||
|
type_str = _type_str(field.type_hint)
|
||||||
|
fk_target = None
|
||||||
|
|
||||||
|
# FK: type name that matches another model
|
||||||
|
if type_str in all_names:
|
||||||
|
fk_target = type_str
|
||||||
|
relationships.append({
|
||||||
|
"from_model": model_def.name,
|
||||||
|
"from_field": field.name,
|
||||||
|
"to_model": fk_target,
|
||||||
|
"type": "FK",
|
||||||
|
})
|
||||||
|
# Django extractor produces "FK" / "M2M" strings
|
||||||
|
elif type_str == "FK":
|
||||||
|
fk_target = None # target unknown from extractor
|
||||||
|
|
||||||
|
fields.append({
|
||||||
|
"name": field.name,
|
||||||
|
"type": type_str,
|
||||||
|
"pk": field.name == "id",
|
||||||
|
"fk": fk_target,
|
||||||
|
"m2m": type_str == "M2M",
|
||||||
|
"nullable": field.optional,
|
||||||
|
})
|
||||||
|
|
||||||
|
models.append({
|
||||||
|
"id": model_def.name,
|
||||||
|
"name": model_def.name,
|
||||||
|
"doc": model_def.docstring or "",
|
||||||
|
"fields": fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"models": models, "relationships": relationships, "source": source}
|
||||||
|
|
||||||
|
|
||||||
|
def _type_str(t: Any) -> str:
|
||||||
|
if t is None:
|
||||||
|
return "Any"
|
||||||
|
if isinstance(t, str):
|
||||||
|
return t
|
||||||
|
if hasattr(t, "__name__"):
|
||||||
|
return t.__name__
|
||||||
|
return str(t)
|
||||||
729
soleprint/station/tools/graphgen/templates/index.html
Normal file
729
soleprint/station/tools/graphgen/templates/index.html
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>graphgen — Schema Explorer</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #1a1a1a;
|
||||||
|
--border: #333;
|
||||||
|
--text: #e5e5e5;
|
||||||
|
--muted: #a3a3a3;
|
||||||
|
--dim: #666;
|
||||||
|
--amber: #d4a574;
|
||||||
|
--amber-dim: #b8956a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar ── */
|
||||||
|
#topbar {
|
||||||
|
height: 44px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar h1 { font-size: 14px; font-weight: 600; color: var(--amber); letter-spacing: .05em; }
|
||||||
|
|
||||||
|
.topbar-sep { color: var(--dim); }
|
||||||
|
|
||||||
|
#schema-info { color: var(--dim); font-size: 12px; }
|
||||||
|
|
||||||
|
.topbar-actions { margin-left: auto; display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--amber); color: var(--amber); }
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
#main { display: flex; flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── Canvas ── */
|
||||||
|
#canvas {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 1px 1px, #1e1e1e 1px, transparent 0) 0 0 / 24px 24px;
|
||||||
|
}
|
||||||
|
#canvas.panning { cursor: grabbing; }
|
||||||
|
|
||||||
|
#viewport {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG for relationship lines — inside viewport */
|
||||||
|
#rels-svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model cards ── */
|
||||||
|
.model-card {
|
||||||
|
position: absolute;
|
||||||
|
width: 210px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
transition: border-color .12s, box-shadow .12s;
|
||||||
|
}
|
||||||
|
.model-card:hover { border-color: #555; }
|
||||||
|
.model-card.selected {
|
||||||
|
border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 0 1px var(--amber)22;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.model-card.dim { opacity: .35; }
|
||||||
|
|
||||||
|
.model-header {
|
||||||
|
background: linear-gradient(135deg, var(--amber), var(--amber-dim));
|
||||||
|
padding: 7px 10px 6px;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.model-header-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0a0a0a;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.model-header-doc {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #0a0a0a;
|
||||||
|
opacity: .65;
|
||||||
|
margin-top: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields { border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #111;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
.field-row:last-child { border-bottom: none; }
|
||||||
|
.field-row:hover { background: rgba(255,255,255,.025); }
|
||||||
|
|
||||||
|
.fbadge {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
.fbadge-pk { background: #7c3aed18; border: 1px solid #7c3aed66; color: #a78bfa; }
|
||||||
|
.fbadge-fk { background: #15803d18; border: 1px solid #15803d66; color: #86efac; }
|
||||||
|
.fbadge-m2m { background: #1d4ed818; border: 1px solid #1d4ed866; color: #93c5fd; }
|
||||||
|
.fbadge-type { background: #22222288; border: 1px solid #44444466; color: var(--dim); }
|
||||||
|
|
||||||
|
.fname {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.fname.nullable { color: var(--muted); }
|
||||||
|
|
||||||
|
.ftype {
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ftype.fk-target { color: #86efac; }
|
||||||
|
|
||||||
|
/* ── Relationship lines ── */
|
||||||
|
.rel-path { fill: none; stroke: var(--dim); stroke-width: 1.5; }
|
||||||
|
.rel-path.hl { stroke: var(--amber); stroke-width: 2; }
|
||||||
|
|
||||||
|
/* ── Sidebar ── */
|
||||||
|
#sidebar {
|
||||||
|
width: 200px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-list { overflow-y: auto; flex: 1; }
|
||||||
|
|
||||||
|
.sb-model {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px solid #111;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background .1s;
|
||||||
|
}
|
||||||
|
.sb-model:hover { background: rgba(255,255,255,.03); }
|
||||||
|
.sb-model.active { color: var(--amber); }
|
||||||
|
|
||||||
|
.sb-model-name { flex: 1; }
|
||||||
|
.sb-model-count { font-size: 10px; color: var(--dim); }
|
||||||
|
|
||||||
|
/* ── Zoom indicator ── */
|
||||||
|
#zoom-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--dim);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
#empty-state {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
#empty-state h2 { font-size: 15px; margin-bottom: 6px; color: var(--muted); }
|
||||||
|
#empty-state p { font-size: 12px; line-height: 1.6; }
|
||||||
|
#empty-state code {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="topbar">
|
||||||
|
<h1>graphgen</h1>
|
||||||
|
<span class="topbar-sep">—</span>
|
||||||
|
<span id="schema-info">loading…</span>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button onclick="autoLayout()">Auto Layout</button>
|
||||||
|
<button onclick="fitView()">Fit View</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main">
|
||||||
|
<div id="canvas">
|
||||||
|
<div id="viewport">
|
||||||
|
<svg id="rels-svg" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<marker id="arr" markerWidth="7" markerHeight="7" refX="5" refY="3.5" orient="auto">
|
||||||
|
<path d="M0,1 L7,3.5 L0,6 Z" fill="#666"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arr-hl" markerWidth="7" markerHeight="7" refX="5" refY="3.5" orient="auto">
|
||||||
|
<path d="M0,1 L7,3.5 L0,6 Z" fill="#d4a574"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div id="empty-state">
|
||||||
|
<h2>No schema found</h2>
|
||||||
|
<p>Add a <code>schema.json</code> to<br><code>cfg/<room>/station/tools/graphgen/</code></p>
|
||||||
|
</div>
|
||||||
|
<div id="zoom-badge">100%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sidebar">
|
||||||
|
<div id="sidebar-header">Models</div>
|
||||||
|
<div id="sidebar-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Constants
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
const CARD_W = 210;
|
||||||
|
const HDR_H = 36; // compact header (no doc)
|
||||||
|
const HDR_H_DOC = 50; // header with doc line
|
||||||
|
const FIELD_H = 26;
|
||||||
|
const COL_GAP = 80;
|
||||||
|
const ROW_GAP = 60;
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// State
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
let schema = null;
|
||||||
|
let positions = {}; // id → {x, y}
|
||||||
|
let selectedId = null;
|
||||||
|
|
||||||
|
let vx = 60, vy = 60, vz = 1; // viewport translate + scale
|
||||||
|
|
||||||
|
// Pan
|
||||||
|
let panning = false, panSX = 0, panSY = 0, panVX0 = 0, panVY0 = 0;
|
||||||
|
|
||||||
|
// Drag
|
||||||
|
let dragging = false, dragId = null, dragSX = 0, dragSY = 0, dragPX0 = 0, dragPY0 = 0;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Boot
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/station/tools/graphgen/api/schema');
|
||||||
|
schema = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('schema-info').textContent = 'fetch error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema.models.length) {
|
||||||
|
document.getElementById('schema-info').textContent = 'no schema';
|
||||||
|
document.getElementById('empty-state').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = schema.models.length;
|
||||||
|
const r2 = schema.relationships.length;
|
||||||
|
document.getElementById('schema-info').textContent =
|
||||||
|
`${m} model${m !== 1 ? 's' : ''} · ${r2} relationship${r2 !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
autoLayout(false);
|
||||||
|
renderSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Layout
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function cardHeight(model) {
|
||||||
|
const hdrH = model.doc ? HDR_H_DOC : HDR_H;
|
||||||
|
return hdrH + model.fields.length * FIELD_H;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoLayout(redraw = true) {
|
||||||
|
if (!schema) return;
|
||||||
|
const { models, relationships } = schema;
|
||||||
|
|
||||||
|
// Models referenced by FKs go first (left-side "root" tables)
|
||||||
|
const isTarget = new Set(relationships.map(r => r.to_model));
|
||||||
|
const sorted = [...models].sort((a, b) =>
|
||||||
|
(isTarget.has(b.id) ? 1 : 0) - (isTarget.has(a.id) ? 1 : 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols = Math.max(2, Math.ceil(Math.sqrt(sorted.length * 1.2)));
|
||||||
|
const colW = CARD_W + COL_GAP;
|
||||||
|
|
||||||
|
// Track per-column cursor Y
|
||||||
|
const colY = Array(cols).fill(40);
|
||||||
|
|
||||||
|
sorted.forEach((model, i) => {
|
||||||
|
const col = i % cols;
|
||||||
|
const x = col * colW + 40;
|
||||||
|
const y = colY[col];
|
||||||
|
positions[model.id] = { x, y };
|
||||||
|
colY[col] += cardHeight(model) + ROW_GAP;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redraw) { renderCards(); renderRels(); }
|
||||||
|
else { renderCards(); renderRels(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Render cards
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function renderCards() {
|
||||||
|
const vp = document.getElementById('viewport');
|
||||||
|
vp.querySelectorAll('.model-card').forEach(c => c.remove());
|
||||||
|
schema.models.forEach(m => vp.appendChild(buildCard(m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCard(model) {
|
||||||
|
const { x, y } = positions[model.id] || { x: 0, y: 0 };
|
||||||
|
|
||||||
|
const card = el('div', 'model-card');
|
||||||
|
card.id = 'card-' + model.id;
|
||||||
|
card.style.left = x + 'px';
|
||||||
|
card.style.top = y + 'px';
|
||||||
|
card.dataset.model = model.id;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const hdr = el('div', 'model-header');
|
||||||
|
hdr.innerHTML = `<div class="model-header-name">${model.name}</div>` +
|
||||||
|
(model.doc ? `<div class="model-header-doc">${model.doc}</div>` : '');
|
||||||
|
card.appendChild(hdr);
|
||||||
|
|
||||||
|
// Fields
|
||||||
|
const fieldsDiv = el('div', 'fields');
|
||||||
|
model.fields.forEach(f => {
|
||||||
|
const row = el('div', 'field-row');
|
||||||
|
row.id = `field-${model.id}-${f.name}`;
|
||||||
|
|
||||||
|
let badgeCls, badgeTxt;
|
||||||
|
if (f.pk) { badgeCls = 'fbadge-pk'; badgeTxt = 'PK'; }
|
||||||
|
else if (f.m2m) { badgeCls = 'fbadge-m2m'; badgeTxt = 'M2M'; }
|
||||||
|
else if (f.fk) { badgeCls = 'fbadge-fk'; badgeTxt = 'FK'; }
|
||||||
|
else { badgeCls = 'fbadge-type'; badgeTxt = shortType(f.type); }
|
||||||
|
|
||||||
|
const nameCls = 'fname' + (f.nullable ? ' nullable' : '');
|
||||||
|
const typeTxt = f.fk ? f.fk : f.type;
|
||||||
|
const typeCls = 'ftype' + (f.fk ? ' fk-target' : '');
|
||||||
|
|
||||||
|
row.innerHTML =
|
||||||
|
`<span class="fbadge ${badgeCls}">${badgeTxt}</span>` +
|
||||||
|
`<span class="${nameCls}">${f.name}</span>` +
|
||||||
|
`<span class="${typeCls}">${typeTxt}</span>`;
|
||||||
|
|
||||||
|
fieldsDiv.appendChild(row);
|
||||||
|
});
|
||||||
|
card.appendChild(fieldsDiv);
|
||||||
|
|
||||||
|
// Drag header
|
||||||
|
hdr.addEventListener('mousedown', e => { e.stopPropagation(); startDrag(e, model.id); });
|
||||||
|
|
||||||
|
// Select
|
||||||
|
card.addEventListener('click', e => { e.stopPropagation(); selectModel(model.id); });
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortType(t) {
|
||||||
|
const m = { 'datetime': 'dt', 'bigint': 'big', 'float': 'f64', 'bool': 'bool', 'dict': '{}', 'list': '[]' };
|
||||||
|
return m[t] || t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Render relationships (SVG inside viewport)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function renderRels() {
|
||||||
|
const svg = document.getElementById('rels-svg');
|
||||||
|
// Remove existing lines
|
||||||
|
[...svg.querySelectorAll('.rel-group')].forEach(g => g.remove());
|
||||||
|
|
||||||
|
if (!schema) return;
|
||||||
|
|
||||||
|
schema.relationships.forEach(rel => renderRel(rel, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRel(rel, highlighted) {
|
||||||
|
const svg = document.getElementById('rels-svg');
|
||||||
|
const fromM = schema.models.find(m => m.id === rel.from_model);
|
||||||
|
const toM = schema.models.find(m => m.id === rel.to_model);
|
||||||
|
if (!fromM || !toM) return;
|
||||||
|
|
||||||
|
const fromPos = positions[rel.from_model];
|
||||||
|
const toPos = positions[rel.to_model];
|
||||||
|
if (!fromPos || !toPos) return;
|
||||||
|
|
||||||
|
const fIdx = fromM.fields.findIndex(f => f.name === rel.from_field);
|
||||||
|
const tIdx = toM.fields.findIndex(f => f.pk) || 0;
|
||||||
|
|
||||||
|
const fromHdrH = fromM.doc ? HDR_H_DOC : HDR_H;
|
||||||
|
const toHdrH = toM.doc ? HDR_H_DOC : HDR_H;
|
||||||
|
|
||||||
|
const x1 = fromPos.x + CARD_W;
|
||||||
|
const y1 = fromPos.y + fromHdrH + (Math.max(fIdx, 0) + 0.5) * FIELD_H;
|
||||||
|
const x2 = toPos.x;
|
||||||
|
const y2 = toPos.y + toHdrH + (Math.max(tIdx, 0) + 0.5) * FIELD_H;
|
||||||
|
|
||||||
|
const dx = Math.abs(x2 - x1);
|
||||||
|
const ctrl = Math.min(Math.max(dx * 0.5, 50), 180);
|
||||||
|
|
||||||
|
const d = `M ${x1},${y1} C ${x1 + ctrl},${y1} ${x2 - ctrl},${y2} ${x2},${y2}`;
|
||||||
|
|
||||||
|
const g = svgEl('g');
|
||||||
|
g.className = 'rel-group';
|
||||||
|
g.dataset.from = rel.from_model;
|
||||||
|
g.dataset.to = rel.to_model;
|
||||||
|
|
||||||
|
const path = svgEl('path');
|
||||||
|
path.setAttribute('d', d);
|
||||||
|
path.setAttribute('class', highlighted ? 'rel-path hl' : 'rel-path');
|
||||||
|
path.setAttribute('marker-end', highlighted ? 'url(#arr-hl)' : 'url(#arr)');
|
||||||
|
|
||||||
|
g.appendChild(path);
|
||||||
|
svg.appendChild(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRelLine(fromId) {
|
||||||
|
const svg = document.getElementById('rels-svg');
|
||||||
|
// Remove and redraw all lines touching fromId
|
||||||
|
[...svg.querySelectorAll('.rel-group')].forEach(g => {
|
||||||
|
if (g.dataset.from === fromId || g.dataset.to === fromId) g.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
schema.relationships.forEach(rel => {
|
||||||
|
if (rel.from_model === fromId || rel.to_model === fromId) {
|
||||||
|
renderRel(rel, selectedId && (rel.from_model === selectedId || rel.to_model === selectedId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Selection
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function selectModel(id) {
|
||||||
|
// Deselect previous
|
||||||
|
document.querySelectorAll('.model-card.selected').forEach(c => c.classList.remove('selected'));
|
||||||
|
document.querySelectorAll('.sb-model.active').forEach(s => s.classList.remove('active'));
|
||||||
|
|
||||||
|
if (selectedId === id) {
|
||||||
|
// Toggle off
|
||||||
|
selectedId = null;
|
||||||
|
document.querySelectorAll('.model-card.dim').forEach(c => c.classList.remove('dim'));
|
||||||
|
renderRels();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedId = id;
|
||||||
|
const connectedIds = getConnected(id);
|
||||||
|
|
||||||
|
document.querySelectorAll('.model-card').forEach(c => {
|
||||||
|
const mid = c.dataset.model;
|
||||||
|
if (mid === id) {
|
||||||
|
c.classList.add('selected');
|
||||||
|
c.classList.remove('dim');
|
||||||
|
} else if (connectedIds.has(mid)) {
|
||||||
|
c.classList.remove('dim');
|
||||||
|
} else {
|
||||||
|
c.classList.add('dim');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
const sbEl = document.querySelector(`.sb-model[data-model="${id}"]`);
|
||||||
|
if (sbEl) sbEl.classList.add('active');
|
||||||
|
|
||||||
|
// Redraw rels with highlight
|
||||||
|
renderRels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnected(id) {
|
||||||
|
const s = new Set([id]);
|
||||||
|
schema.relationships.forEach(r => {
|
||||||
|
if (r.from_model === id) s.add(r.to_model);
|
||||||
|
if (r.to_model === id) s.add(r.from_model);
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Sidebar
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function renderSidebar() {
|
||||||
|
const list = document.getElementById('sidebar-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
schema.models.forEach(m => {
|
||||||
|
const fkCount = m.fields.filter(f => f.fk || f.m2m).length;
|
||||||
|
const div = el('div', 'sb-model');
|
||||||
|
div.dataset.model = m.id;
|
||||||
|
div.innerHTML =
|
||||||
|
`<span class="sb-model-name">${m.name}</span>` +
|
||||||
|
`<span class="sb-model-count">${m.fields.length}f${fkCount ? ' · ' + fkCount + 'r' : ''}</span>`;
|
||||||
|
div.addEventListener('click', () => {
|
||||||
|
selectModel(m.id);
|
||||||
|
scrollToModel(m.id);
|
||||||
|
});
|
||||||
|
list.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToModel(id) {
|
||||||
|
const pos = positions[id];
|
||||||
|
if (!pos) return;
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
|
vx = cw / 2 - (pos.x + CARD_W / 2) * vz;
|
||||||
|
vy = ch / 2 - (pos.y + 100) * vz;
|
||||||
|
applyViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Fit view
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function fitView() {
|
||||||
|
if (!schema || !schema.models.length) return;
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const cw = canvas.clientWidth - 40, ch = canvas.clientHeight - 40;
|
||||||
|
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
schema.models.forEach(m => {
|
||||||
|
const { x, y } = positions[m.id] || { x: 0, y: 0 };
|
||||||
|
const h = cardHeight(m);
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x + CARD_W);
|
||||||
|
maxY = Math.max(maxY, y + h);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sceneW = maxX - minX;
|
||||||
|
const sceneH = maxY - minY;
|
||||||
|
vz = Math.min(1, Math.min(cw / sceneW, ch / sceneH)) * 0.9;
|
||||||
|
vx = (cw - sceneW * vz) / 2 + 20 - minX * vz;
|
||||||
|
vy = (ch - sceneH * vz) / 2 + 20 - minY * vz;
|
||||||
|
|
||||||
|
applyViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Viewport transform
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function applyViewport() {
|
||||||
|
document.getElementById('viewport').style.transform =
|
||||||
|
`translate(${vx}px, ${vy}px) scale(${vz})`;
|
||||||
|
document.getElementById('zoom-badge').textContent = Math.round(vz * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Pan
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', e => {
|
||||||
|
if (e.target !== canvas && !e.target.closest('#viewport') || e.target.closest('.model-card')) return;
|
||||||
|
// only pan when clicking the background (viewport or canvas, not a card)
|
||||||
|
if (e.target.closest('.model-card')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
panning = true;
|
||||||
|
panSX = e.clientX; panSY = e.clientY;
|
||||||
|
panVX0 = vx; panVY0 = vy;
|
||||||
|
canvas.classList.add('panning');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', e => {
|
||||||
|
if (panning) {
|
||||||
|
vx = panVX0 + (e.clientX - panSX);
|
||||||
|
vy = panVY0 + (e.clientY - panSY);
|
||||||
|
applyViewport();
|
||||||
|
}
|
||||||
|
if (dragging && dragId) {
|
||||||
|
const dx = (e.clientX - dragSX) / vz;
|
||||||
|
const dy = (e.clientY - dragSY) / vz;
|
||||||
|
positions[dragId].x = dragPX0 + dx;
|
||||||
|
positions[dragId].y = dragPY0 + dy;
|
||||||
|
const card = document.getElementById('card-' + dragId);
|
||||||
|
if (card) {
|
||||||
|
card.style.left = positions[dragId].x + 'px';
|
||||||
|
card.style.top = positions[dragId].y + 'px';
|
||||||
|
}
|
||||||
|
updateRelLine(dragId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', () => {
|
||||||
|
panning = false;
|
||||||
|
dragging = false;
|
||||||
|
dragId = null;
|
||||||
|
canvas.classList.remove('panning');
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('wheel', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
vx = mx - (mx - vx) * factor;
|
||||||
|
vy = my - (my - vy) * factor;
|
||||||
|
vz = Math.max(0.2, Math.min(3, vz * factor));
|
||||||
|
applyViewport();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
canvas.addEventListener('click', e => {
|
||||||
|
if (e.target === canvas || e.target === document.getElementById('viewport')) {
|
||||||
|
if (selectedId) selectModel(selectedId); // deselect
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Drag cards
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function startDrag(e, id) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
dragId = id;
|
||||||
|
dragSX = e.clientX;
|
||||||
|
dragSY = e.clientY;
|
||||||
|
dragPX0 = positions[id].x;
|
||||||
|
dragPY0 = positions[id].y;
|
||||||
|
const card = document.getElementById('card-' + id);
|
||||||
|
if (card) card.style.zIndex = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function el(tag, cls) {
|
||||||
|
const d = document.createElement(tag);
|
||||||
|
if (cls) d.className = cls;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgEl(tag) {
|
||||||
|
return document.createElementNS(SVG_NS, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
applyViewport();
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user