merge: v5 — cable routing via clamps (all 6 slices)
picasso shipped on a single branch (6 commits @ 813d59b):
- Migration 007: clamps + cable_clamps with PK(cable_id,ord) +
UNIQUE(cable_id,clamp_id). Store helpers (CRUD + Attach with
two-pass shift + Detach gap-close + Reorder).
- HTTP endpoints under /clamps and /cables/:cid/clamps.
- Frontend: +Clamp tool + canvas placement + frame-drag carries
clamps + clamp inspector with cables-through list and
cascade-with-confirm delete.
- Polyline cable render through clamps. Mid-segment drag picks
nearest segment; pointerup snaps to existing clamp within
MID_SNAP_PX/zoom or creates fresh.
- Bundle viz: shared segments get a thick striped overlay (width
min(12,2+N), gradient stripes by count desc / id asc).
×N badge on clamps with ≥2 cables.
- Export: clamps as 12x12 rounded squares (Excalidraw rectangles);
cable arrows carry mid-vertices through clamps; bundle viz stays
viewer-only (Excalidraw can't represent gradient strokes).
This commit is contained in:
351
internal/db/clamps.go
Normal file
351
internal/db/clamps.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClampCreate is the create-shape for a new clamp.
|
||||
type ClampCreate struct {
|
||||
X float64
|
||||
Y float64
|
||||
Label string
|
||||
FrameID *int64
|
||||
}
|
||||
|
||||
// ClampUpdate is the partial-update shape.
|
||||
type ClampUpdate struct {
|
||||
X *float64
|
||||
Y *float64
|
||||
Label *string
|
||||
// FrameID tri-state: nil = leave alone; non-nil pointer to nil ptr
|
||||
// would be ambiguous, so we use FrameRef like devices.
|
||||
FrameID FrameRef
|
||||
}
|
||||
|
||||
// CreateClamp inserts a new clamp inside a project.
|
||||
func (s *Store) CreateClamp(projectID int64, c ClampCreate) (*Clamp, error) {
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.FrameID != nil {
|
||||
if _, err := s.GetFrame(projectID, *c.FrameID); err != nil {
|
||||
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
|
||||
}
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO clamps (project_id, x, y, label, frame_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
projectID, c.X, c.Y, strings.TrimSpace(c.Label), nullableInt64(c.FrameID),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetClamp(projectID, id)
|
||||
}
|
||||
|
||||
// GetClamp returns a single clamp scoped to the project.
|
||||
func (s *Store) GetClamp(projectID, id int64) (*Clamp, error) {
|
||||
var c Clamp
|
||||
var frame sql.NullInt64
|
||||
var ex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
|
||||
FROM clamps WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
c.FrameID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
c.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListClamps returns every clamp in a project, ordered by id.
|
||||
func (s *Store) ListClamps(projectID int64) ([]Clamp, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
|
||||
FROM clamps WHERE project_id = ? ORDER BY id`, projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Clamp{}
|
||||
for rows.Next() {
|
||||
var c Clamp
|
||||
var frame sql.NullInt64
|
||||
var ex sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
c.FrameID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
c.ExcalidrawID = &ex.String
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateClamp applies a partial update.
|
||||
func (s *Store) UpdateClamp(projectID, id int64, u ClampUpdate) (*Clamp, error) {
|
||||
cur, err := s.GetClamp(projectID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.X != nil {
|
||||
cur.X = *u.X
|
||||
}
|
||||
if u.Y != nil {
|
||||
cur.Y = *u.Y
|
||||
}
|
||||
if u.Label != nil {
|
||||
cur.Label = strings.TrimSpace(*u.Label)
|
||||
}
|
||||
if u.FrameID.Set {
|
||||
if u.FrameID.ID == nil {
|
||||
cur.FrameID = nil
|
||||
} else {
|
||||
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
|
||||
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
|
||||
}
|
||||
id := *u.FrameID.ID
|
||||
cur.FrameID = &id
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE clamps SET x = ?, y = ?, label = ?, frame_id = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND project_id = ?`,
|
||||
cur.X, cur.Y, cur.Label, nullableInt64(cur.FrameID), id, projectID,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
return s.GetClamp(projectID, id)
|
||||
}
|
||||
|
||||
// DeleteClamp removes a clamp. cable_clamps rows cascade.
|
||||
func (s *Store) DeleteClamp(projectID, id int64) error {
|
||||
res, err := s.db.Exec(`DELETE FROM clamps WHERE id = ? AND project_id = ?`, id, projectID)
|
||||
if err != nil {
|
||||
return mapWriteErr(err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCableClamps returns every (cable_id, clamp_id, ord) row in a
|
||||
// project, joined through cables to scope by project_id.
|
||||
func (s *Store) ListCableClamps(projectID int64) ([]CableClamp, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT cc.cable_id, cc.clamp_id, cc.ord
|
||||
FROM cable_clamps cc
|
||||
JOIN cables c ON c.id = cc.cable_id
|
||||
WHERE c.project_id = ?
|
||||
ORDER BY cc.cable_id, cc.ord`, projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CableClamp{}
|
||||
for rows.Next() {
|
||||
var cc CableClamp
|
||||
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, cc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListClampsForCable returns the clamps on a cable in ord sequence.
|
||||
func (s *Store) ListClampsForCable(projectID, cableID int64) ([]CableClamp, error) {
|
||||
if _, err := s.GetCable(projectID, cableID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT cable_id, clamp_id, ord
|
||||
FROM cable_clamps WHERE cable_id = ? ORDER BY ord`, cableID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CableClamp{}
|
||||
for rows.Next() {
|
||||
var cc CableClamp
|
||||
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, cc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AttachClampToCable inserts a (cable, clamp) row. If `ord` is 0, the
|
||||
// clamp is appended at the end. Otherwise existing rows at or after
|
||||
// `ord` shift up by 1 to make room.
|
||||
func (s *Store) AttachClampToCable(projectID, cableID, clampID int64, ord int) (*CableClamp, error) {
|
||||
if _, err := s.GetCable(projectID, cableID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.GetClamp(projectID, clampID); err != nil {
|
||||
return nil, fmt.Errorf("%w: clamp not found", ErrInvalidInput)
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Refuse loops — UNIQUE (cable_id, clamp_id) enforces this, but a
|
||||
// pre-check gives a clearer error.
|
||||
var exists int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
||||
cableID, clampID,
|
||||
).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil, fmt.Errorf("%w: clamp %d already on cable %d", ErrConflict, clampID, cableID)
|
||||
}
|
||||
var maxOrd sql.NullInt64
|
||||
if err := tx.QueryRow(
|
||||
`SELECT MAX(ord) FROM cable_clamps WHERE cable_id = ?`, cableID,
|
||||
).Scan(&maxOrd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := 0
|
||||
if maxOrd.Valid {
|
||||
current = int(maxOrd.Int64)
|
||||
}
|
||||
if ord <= 0 || ord > current+1 {
|
||||
ord = current + 1
|
||||
} else if ord <= current {
|
||||
// Shift existing rows at ord..current up by 1 to free the slot.
|
||||
// SQLite UPDATE doesn't support ORDER BY (no UPDATE-with-temp
|
||||
// trick available), so a single `ord = ord + 1` would collide
|
||||
// with the UNIQUE (cable_id, ord) constraint during the bulk
|
||||
// update. Two-pass avoids the conflict: bump to a high offset
|
||||
// first, then settle back to ord+1.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE cable_clamps SET ord = ord + 10000
|
||||
WHERE cable_id = ? AND ord >= ?`, cableID, ord,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE cable_clamps SET ord = ord - 10000 + 1
|
||||
WHERE cable_id = ? AND ord >= ?`, cableID, 10000+ord,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
|
||||
cableID, clampID, ord,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CableClamp{CableID: cableID, ClampID: clampID, Ord: ord}, nil
|
||||
}
|
||||
|
||||
// DetachClampFromCable removes a clamp from a cable's polyline. The
|
||||
// trailing rows close up to keep `ord` contiguous.
|
||||
func (s *Store) DetachClampFromCable(projectID, cableID, clampID int64) error {
|
||||
if _, err := s.GetCable(projectID, cableID); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var removed sql.NullInt64
|
||||
if err := tx.QueryRow(
|
||||
`SELECT ord FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
||||
cableID, clampID,
|
||||
).Scan(&removed); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
||||
cableID, clampID,
|
||||
); err != nil {
|
||||
return mapWriteErr(err)
|
||||
}
|
||||
// Close the gap: anyone with ord > removed slides down by 1.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE cable_clamps SET ord = ord - 1
|
||||
WHERE cable_id = ? AND ord > ?`, cableID, removed.Int64,
|
||||
); err != nil {
|
||||
return mapWriteErr(err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ReorderCableClamps replaces the whole clamp sequence on a cable with
|
||||
// the given clamp IDs, in order. Every member of clampIDs must already
|
||||
// be a valid clamp in the same project; duplicates → ErrConflict.
|
||||
func (s *Store) ReorderCableClamps(projectID, cableID int64, clampIDs []int64) ([]CableClamp, error) {
|
||||
if _, err := s.GetCable(projectID, cableID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for _, cid := range clampIDs {
|
||||
if seen[cid] {
|
||||
return nil, fmt.Errorf("%w: duplicate clamp %d", ErrConflict, cid)
|
||||
}
|
||||
seen[cid] = true
|
||||
if _, err := s.GetClamp(projectID, cid); err != nil {
|
||||
return nil, fmt.Errorf("%w: clamp %d not in project", ErrInvalidInput, cid)
|
||||
}
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM cable_clamps WHERE cable_id = ?`, cableID); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
out := make([]CableClamp, 0, len(clampIDs))
|
||||
for i, cid := range clampIDs {
|
||||
ord := i + 1
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
|
||||
cableID, cid, ord,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
out = append(out, CableClamp{CableID: cableID, ClampID: cid, Ord: ord})
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
188
internal/db/clamps_test.go
Normal file
188
internal/db/clamps_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateClamp_Basic(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
c, err := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 200, Label: "trunk-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if c.X != 100 || c.Y != 200 || c.Label != "trunk-1" {
|
||||
t.Errorf("bad shape: %+v", c)
|
||||
}
|
||||
if c.ProjectID != p.ID {
|
||||
t.Errorf("project_id mismatch: got %d, want %d", c.ProjectID, p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClamp_PositionAndLabel(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
c, _ := s.CreateClamp(p.ID, ClampCreate{X: 0, Y: 0})
|
||||
nx, ny := 50.0, 75.0
|
||||
lbl := "renamed"
|
||||
upd, err := s.UpdateClamp(p.ID, c.ID, ClampUpdate{X: &nx, Y: &ny, Label: &lbl})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if upd.X != 50 || upd.Y != 75 || upd.Label != "renamed" {
|
||||
t.Errorf("update didn't take: %+v", upd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteClamp_CascadesToCableClamps(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
cl, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 50})
|
||||
if _, err := s.AttachClampToCable(p.ID, cab.ID, cl.ID, 0); err != nil {
|
||||
t.Fatalf("attach: %v", err)
|
||||
}
|
||||
if err := s.DeleteClamp(p.ID, cl.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
rows, _ := s.ListClampsForCable(p.ID, cab.ID)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("cable_clamps not cleared: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachClampToCable_AppendsAndOrders(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
|
||||
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
|
||||
cc1, _ := s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
|
||||
cc2, _ := s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0)
|
||||
cc3, _ := s.AttachClampToCable(p.ID, cab.ID, c3.ID, 0)
|
||||
if cc1.Ord != 1 || cc2.Ord != 2 || cc3.Ord != 3 {
|
||||
t.Errorf("ord sequence wrong: %d, %d, %d", cc1.Ord, cc2.Ord, cc3.Ord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachClampToCable_InsertShiftsExisting(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
|
||||
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0) // ord=1
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0) // ord=2
|
||||
// Insert c3 between c1 and c2 → c3 gets ord=2, old c2 bumps to 3.
|
||||
if _, err := s.AttachClampToCable(p.ID, cab.ID, c3.ID, 2); err != nil {
|
||||
t.Fatalf("attach mid: %v", err)
|
||||
}
|
||||
got, _ := s.ListClampsForCable(p.ID, cab.ID)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3: %+v", len(got), got)
|
||||
}
|
||||
want := []struct{ id int64; ord int }{
|
||||
{c1.ID, 1}, {c3.ID, 2}, {c2.ID, 3},
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i].ClampID != w.id || got[i].Ord != w.ord {
|
||||
t.Errorf("[%d] got %+v, want clamp=%d ord=%d", i, got[i], w.id, w.ord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachClampToCable_DuplicateRejected(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
|
||||
if _, err := s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("duplicate err = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachClampFromCable_ClosesGap(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
|
||||
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0)
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c3.ID, 0)
|
||||
if err := s.DetachClampFromCable(p.ID, cab.ID, c2.ID); err != nil {
|
||||
t.Fatalf("detach: %v", err)
|
||||
}
|
||||
got, _ := s.ListClampsForCable(p.ID, cab.ID)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].ClampID != c1.ID || got[0].Ord != 1 {
|
||||
t.Errorf("[0] = %+v", got[0])
|
||||
}
|
||||
if got[1].ClampID != c3.ID || got[1].Ord != 2 {
|
||||
t.Errorf("[1] = %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderCableClamps_FullReplace(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
|
||||
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
|
||||
if _, err := s.ReorderCableClamps(p.ID, cab.ID, []int64{c3.ID, c1.ID, c2.ID}); err != nil {
|
||||
t.Fatalf("reorder: %v", err)
|
||||
}
|
||||
got, _ := s.ListClampsForCable(p.ID, cab.ID)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].ClampID != c3.ID || got[1].ClampID != c1.ID || got[2].ClampID != c2.ID {
|
||||
t.Errorf("order wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot_IncludesClamps(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
_, _ = s.CreateClamp(p.ID, ClampCreate{X: 10, Y: 20})
|
||||
_, _ = s.CreateClamp(p.ID, ClampCreate{X: 30, Y: 40})
|
||||
snap, err := s.Snapshot(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if len(snap.Clamps) != 2 {
|
||||
t.Errorf("clamps in snapshot = %d, want 2", len(snap.Clamps))
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// Caller passes one map per kind; keys are the in-project row ids,
|
||||
// values are the 21-char Excalidraw element ids the exporter minted.
|
||||
func (s *Store) PersistExcalidrawIDs(projectID int64,
|
||||
frames, devices, ports, ios, cables map[int64]string,
|
||||
frames, devices, ports, ios, cables, clamps map[int64]string,
|
||||
) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
@@ -35,6 +35,9 @@ func (s *Store) PersistExcalidrawIDs(projectID int64,
|
||||
if err := updateExIDs(tx, "cables", projectID, cables); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "clamps", projectID, clamps); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
31
internal/db/migrations/007_clamps.sql
Normal file
31
internal/db/migrations/007_clamps.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- mCables v5 — cable routing via clamps. See docs/design.md §11.
|
||||
--
|
||||
-- A clamp is a physical anchor placed on the canvas. A cable's polyline
|
||||
-- runs from its `from` endpoint → its clamps in `ord` sequence → its
|
||||
-- `to` endpoint. Cables that share an ordered pair of consecutive
|
||||
-- clamps are visibly bundled along that segment (computed live by the
|
||||
-- frontend; no detection pass).
|
||||
|
||||
CREATE TABLE clamps (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX clamps_project_idx ON clamps(project_id);
|
||||
CREATE INDEX clamps_frame_idx ON clamps(frame_id);
|
||||
|
||||
CREATE TABLE cable_clamps (
|
||||
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
|
||||
clamp_id INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
|
||||
ord INTEGER NOT NULL, -- 1-based along from→to
|
||||
PRIMARY KEY (cable_id, ord),
|
||||
UNIQUE (cable_id, clamp_id)
|
||||
);
|
||||
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);
|
||||
@@ -220,4 +220,29 @@ type Snapshot struct {
|
||||
Bundles []Bundle `json:"bundles"`
|
||||
CableTypes []CableType `json:"cable_types"`
|
||||
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
|
||||
Clamps []Clamp `json:"clamps"`
|
||||
CableClamps []CableClamp `json:"cable_clamps"`
|
||||
}
|
||||
|
||||
// Clamp is a routing anchor on the canvas. Cables route through clamps
|
||||
// in `ord` sequence (see cable_clamps), giving m a physical handle on
|
||||
// where bundles converge.
|
||||
type Clamp struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Label string `json:"label"`
|
||||
FrameID *int64 `json:"frame_id"`
|
||||
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CableClamp is one (cable, clamp, ord) row. Ord is 1-based along the
|
||||
// cable's from→to direction.
|
||||
type CableClamp struct {
|
||||
CableID int64 `json:"cable_id"`
|
||||
ClampID int64 `json:"clamp_id"`
|
||||
Ord int `json:"ord"`
|
||||
}
|
||||
|
||||
@@ -187,6 +187,14 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clamps, err := s.ListClamps(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cableClamps, err := s.ListCableClamps(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Snapshot{
|
||||
Project: *p,
|
||||
Frames: frames,
|
||||
@@ -197,6 +205,8 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
||||
Bundles: bundles,
|
||||
CableTypes: types,
|
||||
ConnectionRequirements: reqs,
|
||||
Clamps: clamps,
|
||||
CableClamps: cableClamps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
@@ -114,6 +115,7 @@ type IDAssignment struct {
|
||||
Ports map[int64]string `json:"ports"`
|
||||
IOMarkers map[int64]string `json:"io_markers"`
|
||||
Cables map[int64]string `json:"cables"`
|
||||
Clamps map[int64]string `json:"clamps"`
|
||||
}
|
||||
|
||||
// BuildScene transforms a project snapshot into an Excalidraw Scene +
|
||||
@@ -132,6 +134,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
|
||||
Ports: map[int64]string{},
|
||||
IOMarkers: map[int64]string{},
|
||||
Cables: map[int64]string{},
|
||||
Clamps: map[int64]string{},
|
||||
}
|
||||
// idFor: reuse the existing excalidraw_id if present, else mint one.
|
||||
idFor := func(existing *string) string {
|
||||
@@ -381,6 +384,58 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
|
||||
})
|
||||
}
|
||||
|
||||
// Clamps — small grey rounded squares (v5 §11.7). Distinct from the
|
||||
// red IO marker diamonds so m can tell routing anchors from wall
|
||||
// outlets at a glance.
|
||||
const clampSize = 12.0
|
||||
for _, cl := range snap.Clamps {
|
||||
elID := idFor(cl.ExcalidrawID)
|
||||
a.Clamps[cl.ID] = elID
|
||||
var frameRef *string
|
||||
if cl.FrameID != nil {
|
||||
if v, ok := frameElID[*cl.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "rectangle",
|
||||
X: cl.X - clampSize/2,
|
||||
Y: cl.Y - clampSize/2,
|
||||
Width: clampSize,
|
||||
Height: clampSize,
|
||||
StrokeColor: "#555555",
|
||||
BackgroundColor: "#888888",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 1,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 3},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-group cable_clamps by cable for the arrow mid-points pass.
|
||||
clampsByCable := map[int64][]db.CableClamp{}
|
||||
for _, cc := range snap.CableClamps {
|
||||
clampsByCable[cc.CableID] = append(clampsByCable[cc.CableID], cc)
|
||||
}
|
||||
for _, arr := range clampsByCable {
|
||||
// Already sorted by ListCableClamps (ORDER BY cable_id, ord),
|
||||
// but defend against unsorted inputs.
|
||||
sort.Slice(arr, func(i, j int) bool { return arr[i].Ord < arr[j].Ord })
|
||||
}
|
||||
clampPos := map[int64][2]float64{}
|
||||
for _, cl := range snap.Clamps {
|
||||
clampPos[cl.ID] = [2]float64{cl.X, cl.Y}
|
||||
}
|
||||
|
||||
// Cables — arrows with startBinding/endBinding to the port / device /
|
||||
// IO marker excalidraw_ids. Endpoint anchors (the visible "from" /
|
||||
// "to" points) come from the same anchor logic the canvas uses.
|
||||
@@ -403,6 +458,18 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
|
||||
}
|
||||
startArr := ""
|
||||
endArr := "arrow"
|
||||
// Excalidraw arrow `points` is relative to (X, Y). We anchor at
|
||||
// the from-point, so vertex 0 is always (0, 0). Mid-vertices
|
||||
// (clamps) and the final to-vertex are offsets from there.
|
||||
pts := [][2]float64{{0, 0}}
|
||||
for _, cc := range clampsByCable[c.ID] {
|
||||
pos, ok := clampPos[cc.ClampID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pts = append(pts, [2]float64{pos[0] - fromAnchor[0], pos[1] - fromAnchor[1]})
|
||||
}
|
||||
pts = append(pts, [2]float64{toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]})
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "arrow",
|
||||
@@ -422,7 +489,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Points: [][2]float64{{0, 0}, {toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}},
|
||||
Points: pts,
|
||||
StartArrowhead: &startArr,
|
||||
EndArrowhead: &endArr,
|
||||
StartBinding: bindingPtr(fromRef),
|
||||
|
||||
@@ -137,6 +137,66 @@ func TestBuildScene_BundlesIgnored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ClampsRenderAsRectangles(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
snap.Clamps = []db.Clamp{
|
||||
{ID: 1, ProjectID: 1, X: 500, Y: 300},
|
||||
{ID: 2, ProjectID: 1, X: 550, Y: 320},
|
||||
}
|
||||
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
if len(ids.Clamps) != 2 {
|
||||
t.Errorf("clamp ids = %d, want 2", len(ids.Clamps))
|
||||
}
|
||||
clampElIDs := map[string]bool{}
|
||||
for _, id := range ids.Clamps {
|
||||
clampElIDs[id] = true
|
||||
}
|
||||
got := 0
|
||||
for _, e := range scene.Elements {
|
||||
if clampElIDs[e.ID] && e.Type == "rectangle" {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if got != 2 {
|
||||
t.Errorf("clamp rectangle elements = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ArrowPointsIncludeClamps(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
snap.Clamps = []db.Clamp{
|
||||
{ID: 10, ProjectID: 1, X: 350, Y: 250},
|
||||
}
|
||||
snap.CableClamps = []db.CableClamp{
|
||||
{CableID: 1000, ClampID: 10, Ord: 1},
|
||||
}
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
var arrow *Element
|
||||
for i := range scene.Elements {
|
||||
if scene.Elements[i].Type == "arrow" {
|
||||
arrow = &scene.Elements[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if arrow == nil {
|
||||
t.Fatal("no arrow in scene")
|
||||
}
|
||||
if len(arrow.Points) != 3 {
|
||||
t.Errorf("arrow points = %d, want 3 (from + clamp + to): %+v", len(arrow.Points), arrow.Points)
|
||||
}
|
||||
// First point is always (0, 0) by convention; middle point should
|
||||
// equal the clamp's position relative to the arrow's anchor.
|
||||
if arrow.Points[0][0] != 0 || arrow.Points[0][1] != 0 {
|
||||
t.Errorf("first point = %v, want [0,0]", arrow.Points[0])
|
||||
}
|
||||
// Middle vertex = clamp.x - fromAnchor.x, clamp.y - fromAnchor.y.
|
||||
// fromAnchor for port 100 = (200 + 50, 200 + 35) = (250, 235).
|
||||
wantX, wantY := 350.0-250.0, 250.0-235.0
|
||||
if arrow.Points[1][0] != wantX || arrow.Points[1][1] != wantY {
|
||||
t.Errorf("mid point = %v, want [%v, %v]", arrow.Points[1], wantX, wantY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalScene_IsJSON(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
|
||||
195
internal/server/clamps.go
Normal file
195
internal/server/clamps.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
type clampCreate struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Label string `json:"label,omitempty"`
|
||||
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
||||
}
|
||||
|
||||
type clampPatch struct {
|
||||
X *float64 `json:"x,omitempty"`
|
||||
Y *float64 `json:"y,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
||||
}
|
||||
|
||||
type cableClampAttach struct {
|
||||
ClampID int64 `json:"clamp_id"`
|
||||
Ord int `json:"ord,omitempty"`
|
||||
}
|
||||
|
||||
type cableClampReorder struct {
|
||||
ClampIDs []int64 `json:"clamp_ids"`
|
||||
}
|
||||
|
||||
func (h *handlers) listClamps(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cs, err := h.store.ListClamps(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cs)
|
||||
}
|
||||
|
||||
func (h *handlers) createClamp(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body clampCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
ref, err := parseFrameRef(body.FrameID)
|
||||
if err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
c, err := h.store.CreateClamp(pid, db.ClampCreate{
|
||||
X: body.X, Y: body.Y, Label: body.Label, FrameID: ref.ID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (h *handlers) patchClamp(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body clampPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
ref, err := parseFrameRef(body.FrameID)
|
||||
if err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
c, err := h.store.UpdateClamp(pid, id, db.ClampUpdate{
|
||||
X: body.X, Y: body.Y, Label: body.Label, FrameID: ref,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteClamp(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteClamp(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/projects/:pid/cables/:cid/clamps — attach a clamp to a cable.
|
||||
func (h *handlers) attachClampToCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cid, ok := parseInt64Path(r, "cid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cableClampAttach
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
cc, err := h.store.AttachClampToCable(pid, cid, body.ClampID, body.Ord)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, cc)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/:pid/cables/:cid/clamps/:cmid — detach a clamp.
|
||||
func (h *handlers) detachClampFromCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cid, ok := parseInt64Path(r, "cid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cmid, ok := parseInt64Path(r, "cmid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "cmid must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DetachClampFromCable(pid, cid, cmid); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PUT /api/projects/:pid/cables/:cid/clamps — replace clamp sequence.
|
||||
func (h *handlers) reorderCableClamps(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cid, ok := parseInt64Path(r, "cid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cableClampReorder
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
out, err := h.store.ReorderCableClamps(pid, cid, body.ClampIDs)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
|
||||
// Persist the freshly-assigned ids so the next export reuses them.
|
||||
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it
|
||||
// only updates rows whose excalidraw_id is still NULL).
|
||||
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables); err != nil {
|
||||
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables, ids.Clamps); err != nil {
|
||||
writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,6 +93,15 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
// Slice 8 — export to mxdrw.msbls.de
|
||||
mux.HandleFunc("POST /api/projects/{pid}/sync/export", h.syncExport)
|
||||
|
||||
// v5 — clamps + cable routing.
|
||||
mux.HandleFunc("GET /api/projects/{pid}/clamps", h.listClamps)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/clamps", h.createClamp)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/clamps/{id}", h.patchClamp)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/clamps/{id}", h.deleteClamp)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/cables/{cid}/clamps", h.attachClampToCable)
|
||||
mux.HandleFunc("PUT /api/projects/{pid}/cables/{cid}/clamps", h.reorderCableClamps)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/cables/{cid}/clamps/{cmid}", h.detachClampFromCable)
|
||||
|
||||
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
||||
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
||||
// the file server already emits — without this, browsers cache aggressively
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
|
||||
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
|
||||
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
|
||||
<li><button type="button" id="tool-clamp" class="btn btn-tiny" data-tool="clamp" title="Click canvas to drop a clamp. Cables can then route through it.">+ Clamp</button></li>
|
||||
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
|
||||
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
|
||||
</ul>
|
||||
@@ -51,10 +52,13 @@
|
||||
|
||||
<section class="canvas-wrap" aria-label="Diagram">
|
||||
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
|
||||
<defs id="canvas-defs"></defs>
|
||||
<g id="canvas-frames"></g>
|
||||
<g id="canvas-devices"></g>
|
||||
<g id="canvas-ports"></g>
|
||||
<g id="canvas-cables"></g>
|
||||
<g id="canvas-bundles"></g>
|
||||
<g id="canvas-clamps"></g>
|
||||
<g id="canvas-io"></g>
|
||||
</svg>
|
||||
<p id="empty-hint" class="empty-hint">
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
const API = "/api";
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/height)
|
||||
const CLAMP_SIZE = 12; // small rounded square for routing clamps (v5 §11)
|
||||
|
||||
const state = {
|
||||
/** @type {Project[]} */ projects: [],
|
||||
@@ -55,8 +56,11 @@ const state = {
|
||||
/** @type {Cable[]} */ cables: [],
|
||||
/** @type {Bundle[]} */ bundles: [],
|
||||
/** @type {SetupTemplate[]} */ setupTemplates: [],
|
||||
/** v5 — routing anchors. */
|
||||
/** @type {Clamp[]} */ clamps: [],
|
||||
/** @type {CableClamp[]} */ cableClamps: [],
|
||||
activeTypeId: /** @type {number|null} */ (null),
|
||||
/** "frame" | "device" | "io" | "req" | "cable" | null */
|
||||
/** "frame" | "device" | "io" | "req" | "cable" | "clamp" | null */
|
||||
tool: /** @type {string|null} */ (null),
|
||||
/** Canvas viewport — drives the SVG viewBox. */
|
||||
view: { x: 0, y: 0, zoom: 1 },
|
||||
@@ -64,7 +68,7 @@ const state = {
|
||||
spaceHeld: false,
|
||||
/** Slice-7: when the user clicked a source port, this is its id. */
|
||||
cableDrawFromPortID: /** @type {number|null} */ (null),
|
||||
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
|
||||
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port"|"clamp", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
|
||||
};
|
||||
|
||||
// ---------- API client ---------- //
|
||||
@@ -135,6 +139,15 @@ const listSetupTemplates = () => api("GET", `/setup-templates`);
|
||||
const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body);
|
||||
const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {});
|
||||
|
||||
// v5 — clamps + cable_clamps.
|
||||
const listClamps = (pid) => api("GET", `/projects/${pid}/clamps`);
|
||||
const createClamp = (pid, body) => api("POST", `/projects/${pid}/clamps`, body);
|
||||
const patchClamp = (pid, id, body) => api("PATCH", `/projects/${pid}/clamps/${id}`, body);
|
||||
const deleteClamp = (pid, id) => api("DELETE", `/projects/${pid}/clamps/${id}`);
|
||||
const attachClampToCable = (pid, cid, body) => api("POST", `/projects/${pid}/cables/${cid}/clamps`, body);
|
||||
const detachClampFromCable = (pid, cid, cmid) => api("DELETE", `/projects/${pid}/cables/${cid}/clamps/${cmid}`);
|
||||
const reorderCableClamps = (pid, cid, body) => api("PUT", `/projects/${pid}/cables/${cid}/clamps`, body);
|
||||
|
||||
// ---------- DOM helpers ---------- //
|
||||
|
||||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||||
@@ -393,11 +406,17 @@ function renderCanvas() {
|
||||
const gFrames = $("#canvas-frames");
|
||||
const gDevices = $("#canvas-devices");
|
||||
const gCables = $("#canvas-cables");
|
||||
const gBundles = $("#canvas-bundles");
|
||||
const gClamps = $("#canvas-clamps");
|
||||
const gIO = $("#canvas-io");
|
||||
const gDefs = $("#canvas-defs");
|
||||
gFrames.innerHTML = "";
|
||||
gDevices.innerHTML = "";
|
||||
gCables.innerHTML = "";
|
||||
gBundles.innerHTML = "";
|
||||
gClamps.innerHTML = "";
|
||||
gIO.innerHTML = "";
|
||||
gDefs.innerHTML = "";
|
||||
|
||||
for (const f of state.frames) {
|
||||
const g = svgEl("g", { "data-frame-id": f.id });
|
||||
@@ -539,30 +558,103 @@ function renderCanvas() {
|
||||
});
|
||||
}
|
||||
|
||||
// Cables — straight lines between resolved endpoint anchors.
|
||||
// Auto-cables render with dashed stroke so m sees which the solver
|
||||
// placed; manual cables are solid.
|
||||
// Clamps — small grey rounded squares (per design v5 §11.9 q1).
|
||||
// Slice 4 wires them into cable polylines; for slice 3 they just
|
||||
// render + drag + select. Slice 5 adds a ×N count badge for clamps
|
||||
// with ≥2 cables through them.
|
||||
const cablesPerClamp = new Map();
|
||||
for (const cc of state.cableClamps) {
|
||||
cablesPerClamp.set(cc.clamp_id, (cablesPerClamp.get(cc.clamp_id) || 0) + 1);
|
||||
}
|
||||
for (const cl of state.clamps) {
|
||||
const g = svgEl("g", { "data-clamp-id": cl.id });
|
||||
const sz = CLAMP_SIZE;
|
||||
const rect = svgEl("rect", {
|
||||
x: cl.x - sz / 2, y: cl.y - sz / 2, width: sz, height: sz,
|
||||
rx: 2, ry: 2,
|
||||
class: "clamp" + (state.selection?.kind === "clamp" && state.selection.id === cl.id ? " selected" : "") + " svg-draggable",
|
||||
});
|
||||
g.append(rect);
|
||||
const n = cablesPerClamp.get(cl.id) || 0;
|
||||
if (n >= 2) {
|
||||
const badge = svgEl("text", {
|
||||
x: cl.x + sz / 2 + 2, y: cl.y - sz / 2 - 1,
|
||||
class: "clamp-badge",
|
||||
});
|
||||
badge.textContent = `×${n}`;
|
||||
g.append(badge);
|
||||
}
|
||||
if (cl.label) {
|
||||
const label = svgEl("text", {
|
||||
x: cl.x + sz / 2 + 4, y: cl.y + 3,
|
||||
class: "clamp-label",
|
||||
});
|
||||
label.textContent = cl.label;
|
||||
g.append(label);
|
||||
}
|
||||
gClamps.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "clamp", cl.id));
|
||||
}
|
||||
|
||||
// Cables — polyline through endpoint(from) → clamps in ord sequence
|
||||
// → endpoint(to). With zero clamps this collapses to a v0 straight
|
||||
// line. Auto-cables render dashed; manual solid.
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||||
const clampByID = new Map(state.clamps.map((cl) => [cl.id, cl]));
|
||||
// Pre-group cable_clamps by cable, sorted by ord.
|
||||
const clampsByCable = new Map();
|
||||
for (const cc of state.cableClamps) {
|
||||
let arr = clampsByCable.get(cc.cable_id);
|
||||
if (!arr) { arr = []; clampsByCable.set(cc.cable_id, arr); }
|
||||
arr.push(cc);
|
||||
}
|
||||
for (const arr of clampsByCable.values()) arr.sort((a, b) => a.ord - b.ord);
|
||||
|
||||
// sharedSegments: segmentKey → { a, b, cables:[Cable] }. Built up
|
||||
// during the per-cable loop, then walked in a second pass for the
|
||||
// bundle overlay layer (v5 §11.3).
|
||||
const sharedSegments = new Map();
|
||||
|
||||
for (const c of state.cables) {
|
||||
let fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
|
||||
let toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
|
||||
if (!fromAnchor || !toAnchor) continue;
|
||||
const built = cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable);
|
||||
if (!built) continue;
|
||||
const { vertices, keys } = built;
|
||||
// Bundle accumulator — record this cable on every segment of its
|
||||
// resolved polyline keyed by an undirected pair of vertex IDs.
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const a = keys[i], b = keys[i + 1];
|
||||
const segKey = a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
let bucket = sharedSegments.get(segKey);
|
||||
if (!bucket) {
|
||||
bucket = { a: vertices[i], b: vertices[i + 1], cables: [] };
|
||||
sharedSegments.set(segKey, bucket);
|
||||
}
|
||||
bucket.cables.push(c);
|
||||
}
|
||||
// Replug preview: while m drags an endpoint handle, override the
|
||||
// affected end with the live cursor world position so the line
|
||||
// tracks the pointer.
|
||||
// tracks the pointer. Mid-vertices (clamps) are unchanged.
|
||||
if (cableReplug && cableReplug.cableID === c.id) {
|
||||
if (cableReplug.end === "from") fromAnchor = { x: cableReplug.x, y: cableReplug.y };
|
||||
else toAnchor = { x: cableReplug.x, y: cableReplug.y };
|
||||
const idx = cableReplug.end === "from" ? 0 : vertices.length - 1;
|
||||
vertices[idx] = { x: cableReplug.x, y: cableReplug.y };
|
||||
}
|
||||
// Mid-segment drag preview: while m is bending a segment, insert
|
||||
// a temp vertex at the cursor so the line tracks. On release this
|
||||
// becomes a real clamp (or snaps to a nearby existing one).
|
||||
if (cableMidDrag && cableMidDrag.cableID === c.id) {
|
||||
const at = cableMidDrag.segmentIdx + 1;
|
||||
vertices.splice(at, 0, { x: cableMidDrag.x, y: cableMidDrag.y });
|
||||
}
|
||||
const isSelected = state.selection?.kind === "cable" && state.selection.id === c.id;
|
||||
const color = cableTypeColor.get(c.type_id) || "#888";
|
||||
const line = svgEl("line", {
|
||||
x1: fromAnchor.x, y1: fromAnchor.y,
|
||||
x2: toAnchor.x, y2: toAnchor.y,
|
||||
const pointsStr = vertices.map((v) => `${v.x},${v.y}`).join(" ");
|
||||
const line = svgEl("polyline", {
|
||||
points: pointsStr,
|
||||
class: "cable-line" + (c.auto ? " auto" : "") + (isSelected ? " selected" : ""),
|
||||
stroke: color,
|
||||
fill: "none",
|
||||
"data-cable-id": c.id,
|
||||
});
|
||||
line.addEventListener("click", (e) => {
|
||||
@@ -570,12 +662,20 @@ function renderCanvas() {
|
||||
state.selection = { kind: "cable", id: c.id };
|
||||
render();
|
||||
});
|
||||
line.addEventListener("pointerdown", (e) => {
|
||||
// Selected cable + non-endpoint click → start a mid-segment drag
|
||||
// that inserts (or snaps to) a clamp on release. Bypasses the
|
||||
// canvas-level handler so panning / device drag don't fire.
|
||||
if (isSelected && e.button === 0 && !state.spaceHeld) {
|
||||
startCableMidDrag(e, c, vertices);
|
||||
}
|
||||
});
|
||||
gCables.append(line);
|
||||
// Endpoint handles — only on the currently-selected cable. Two small
|
||||
// filled circles m can grab to drag the endpoint onto a new target.
|
||||
// Endpoint handles — first + last vertex when selected.
|
||||
if (isSelected) {
|
||||
for (const end of ["from", "to"]) {
|
||||
const a = end === "from" ? fromAnchor : toAnchor;
|
||||
const first = vertices[0];
|
||||
const last = vertices[vertices.length - 1];
|
||||
for (const [end, a] of [["from", first], ["to", last]]) {
|
||||
const h = svgEl("circle", {
|
||||
cx: a.x, cy: a.y, r: 7,
|
||||
class: "cable-handle",
|
||||
@@ -589,6 +689,97 @@ function renderCanvas() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bundle viz: shared segments + clamp count badges (v5 §11.3) ----
|
||||
let gradSeq = 0;
|
||||
for (const [segKey, bucket] of sharedSegments) {
|
||||
if (bucket.cables.length < 2) continue;
|
||||
// Distinct cable type IDs in this bundle, ordered by count desc
|
||||
// (ties by id asc) per design v5 §11.9 q4.
|
||||
const counts = new Map();
|
||||
for (const c of bucket.cables) {
|
||||
counts.set(c.type_id, (counts.get(c.type_id) || 0) + 1);
|
||||
}
|
||||
const distinctTypes = [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
|
||||
.map(([id]) => id);
|
||||
// Build a linearGradient perpendicular to the segment so the stripes
|
||||
// run ACROSS the segment's thickness (visually: stripes parallel to
|
||||
// the cable direction).
|
||||
const { a, b } = bucket;
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
// Perpendicular unit vector — gradient runs along this so the stops
|
||||
// become bands along the segment's direction.
|
||||
const px = -dy / len, py = dx / len;
|
||||
const thickness = Math.min(12, 2 + bucket.cables.length);
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||||
// Stops: hard-edged segments, one band per type.
|
||||
const gradID = `bundle-grad-${gradSeq++}-${segKey.replace(/[^a-z0-9-]/gi, "_")}`;
|
||||
const grad = svgEl("linearGradient", {
|
||||
id: gradID,
|
||||
gradientUnits: "userSpaceOnUse",
|
||||
x1: mx + px * thickness / 2,
|
||||
y1: my + py * thickness / 2,
|
||||
x2: mx - px * thickness / 2,
|
||||
y2: my - py * thickness / 2,
|
||||
});
|
||||
const n = distinctTypes.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const color = cableTypeColor.get(distinctTypes[i]) || "#888";
|
||||
const startStop = svgEl("stop", { offset: `${(i / n) * 100}%`, "stop-color": color });
|
||||
const endStop = svgEl("stop", { offset: `${((i + 1) / n) * 100}%`, "stop-color": color });
|
||||
grad.append(startStop, endStop);
|
||||
}
|
||||
gDefs.append(grad);
|
||||
// Tooltip listing the bundled cable types.
|
||||
const titleText = distinctTypes
|
||||
.map((id) => cableTypeColor.has(id) ? state.cableTypes.find((t) => t.id === id)?.name ?? `#${id}` : `#${id}`)
|
||||
.join(" · ") + ` (${bucket.cables.length} cables)`;
|
||||
const overlay = svgEl("line", {
|
||||
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
|
||||
class: "bundle-line",
|
||||
stroke: `url(#${gradID})`,
|
||||
"stroke-width": thickness,
|
||||
});
|
||||
const title = svgEl("title", {});
|
||||
title.textContent = titleText;
|
||||
overlay.append(title);
|
||||
gBundles.append(overlay);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Compute the resolved polyline vertices for a cable plus a stable
|
||||
// vertex-key per vertex used to detect shared segments for bundle viz.
|
||||
// Vertex keys:
|
||||
// - port:<id> for a port-anchored endpoint
|
||||
// - device:<id> for a device-anchored endpoint (no port)
|
||||
// - io:<id> for an IO-anchored endpoint
|
||||
// - clamp:<id> for a mid-vertex
|
||||
// Returns null if either endpoint can't be resolved.
|
||||
function cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable) {
|
||||
const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
|
||||
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
|
||||
if (!fromAnchor || !toAnchor) return null;
|
||||
function endpointKey(portID, deviceID, ioID) {
|
||||
if (portID != null) return `port:${portID}`;
|
||||
if (deviceID != null) return `device:${deviceID}`;
|
||||
return `io:${ioID}`;
|
||||
}
|
||||
const vertices = [fromAnchor];
|
||||
const keys = [endpointKey(c.from_port_id, c.from_device_id, c.from_io_id)];
|
||||
const clamps = clampsByCable.get(c.id) || [];
|
||||
for (const cc of clamps) {
|
||||
const cl = clampByID.get(cc.clamp_id);
|
||||
if (cl) {
|
||||
vertices.push({ x: cl.x, y: cl.y });
|
||||
keys.push(`clamp:${cl.id}`);
|
||||
}
|
||||
}
|
||||
vertices.push(toAnchor);
|
||||
keys.push(endpointKey(c.to_port_id, c.to_device_id, c.to_io_id));
|
||||
return { vertices, keys };
|
||||
}
|
||||
|
||||
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
|
||||
@@ -629,6 +820,7 @@ function renderInspector() {
|
||||
case "cable": return renderInspectorCable(body, state.selection.id);
|
||||
case "port": return renderInspectorPort(body, state.selection.id);
|
||||
case "port_new": return renderInspectorPortNew(body, state.selection.device_id);
|
||||
case "clamp": return renderInspectorClamp(body, state.selection.id);
|
||||
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||||
}
|
||||
}
|
||||
@@ -1217,6 +1409,112 @@ function renderInspectorIO(body, id) {
|
||||
});
|
||||
}
|
||||
|
||||
// Clamp inspector — label + position + cables-through list + delete.
|
||||
// Slice 4 wires the cables-through list to actual data; for slice 3 it
|
||||
// reads whatever's already on state.cableClamps (initially empty for a
|
||||
// freshly-placed clamp).
|
||||
function renderInspectorClamp(body, id) {
|
||||
const cl = state.clamps.find((x) => x.id === id);
|
||||
if (!cl) { body.innerHTML = ""; return; }
|
||||
const frame = cl.frame_id ? state.frames.find((f) => f.id === cl.frame_id) : null;
|
||||
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||||
const cablesThrough = state.cableClamps
|
||||
.filter((cc) => cc.clamp_id === id)
|
||||
.map((cc) => state.cables.find((c) => c.id === cc.cable_id))
|
||||
.filter(Boolean);
|
||||
function endpointLabel(c, end) {
|
||||
const portID = end === "from" ? c.from_port_id : c.to_port_id;
|
||||
const devID = end === "from" ? c.from_device_id : c.to_device_id;
|
||||
const ioID = end === "from" ? c.from_io_id : c.to_io_id;
|
||||
if (portID != null) {
|
||||
const p = portByID.get(portID);
|
||||
const d = p && deviceByID.get(p.device_id);
|
||||
return `${d?.name ?? "?"} · ${p?.label ?? "port"}`;
|
||||
}
|
||||
if (devID != null) return deviceByID.get(devID)?.name ?? "(missing device)";
|
||||
if (ioID != null) return ioByID.get(ioID)?.label ?? "(missing IO)";
|
||||
return "?";
|
||||
}
|
||||
const cablesHtml = cablesThrough.length
|
||||
? cablesThrough.map((c) => `
|
||||
<div class="port-row" data-cable-id="${c.id}">
|
||||
<span class="swatch" style="background:${cableTypeColor.get(c.type_id) || "#888"}"></span>
|
||||
<span class="label">${escapeHtml(endpointLabel(c, "from"))} ↔ ${escapeHtml(endpointLabel(c, "to"))}</span>
|
||||
<span class="conn">
|
||||
<button type="button" class="btn-link clamp-detach" data-cable-id="${c.id}" title="Remove this clamp from the cable">×</button>
|
||||
</span>
|
||||
</div>`).join("")
|
||||
: `<p class="muted" style="font-size:12px">Not on any cable yet.</p>`;
|
||||
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Clamp</p>
|
||||
<label class="field">
|
||||
<span>Label</span>
|
||||
<input class="inline-input" id="clamp-label" value="" />
|
||||
</label>
|
||||
<dl>
|
||||
<dt>x</dt><dd id="clamp-x"></dd>
|
||||
<dt>y</dt><dd id="clamp-y"></dd>
|
||||
<dt>frame</dt><dd id="clamp-frame"></dd>
|
||||
</dl>
|
||||
<p class="section-title">Cables through</p>
|
||||
${cablesHtml}
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="clamp-delete">Delete clamp</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#clamp-label").value = cl.label;
|
||||
body.querySelector("#clamp-x").textContent = cl.x.toFixed(0);
|
||||
body.querySelector("#clamp-y").textContent = cl.y.toFixed(0);
|
||||
body.querySelector("#clamp-frame").textContent = frame ? frame.name : "—";
|
||||
|
||||
bindDebouncedRename(body.querySelector("#clamp-label"), async (label) => {
|
||||
if (!state.active) return;
|
||||
const updated = await patchClamp(state.active.id, cl.id, { label });
|
||||
Object.assign(cl, updated);
|
||||
renderCanvas();
|
||||
});
|
||||
|
||||
// Per-cable detach in the cables-through list.
|
||||
body.querySelectorAll(".clamp-detach").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!state.active) return;
|
||||
const cableID = Number(btn.getAttribute("data-cable-id"));
|
||||
try {
|
||||
await detachClampFromCable(state.active.id, cableID, cl.id);
|
||||
// Re-fetch snapshot fragment to keep ord contiguous.
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cableClamps = snap.cable_clamps || [];
|
||||
render();
|
||||
} catch (ex) {
|
||||
alert(`Detach failed: ${ex.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelector("#clamp-delete").addEventListener("click", async () => {
|
||||
if (!state.active) return;
|
||||
const n = cablesThrough.length;
|
||||
const prompt = n > 0
|
||||
? `This clamp is on ${n} cable(s). Delete it and remove from all of them?`
|
||||
: "Delete this clamp?";
|
||||
if (!confirm(prompt)) return;
|
||||
try {
|
||||
await deleteClamp(state.active.id, cl.id);
|
||||
state.clamps = state.clamps.filter((c) => c.id !== id);
|
||||
state.cableClamps = state.cableClamps.filter((cc) => cc.clamp_id !== id);
|
||||
state.selection = null;
|
||||
render();
|
||||
} catch (ex) {
|
||||
alert(`Delete failed: ${ex.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Port editor — type / edge / label / delete. m can also navigate back
|
||||
// to the device by clicking "back to device" or anywhere on the device.
|
||||
function renderInspectorPort(body, id) {
|
||||
@@ -1568,6 +1866,8 @@ async function activateProject(id) {
|
||||
state.requirements = [];
|
||||
state.cables = [];
|
||||
state.bundles = [];
|
||||
state.clamps = [];
|
||||
state.cableClamps = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
@@ -1584,6 +1884,8 @@ async function activateProject(id) {
|
||||
state.bundles = snap.bundles || [];
|
||||
state.requirements = snap.connection_requirements || [];
|
||||
state.cableTypes = snap.cable_types || [];
|
||||
state.clamps = snap.clamps || [];
|
||||
state.cableClamps = snap.cable_clamps || [];
|
||||
state.selection = null;
|
||||
setActiveInURL(id);
|
||||
// Hydrate the device-type catalog for this project — used by the
|
||||
@@ -1607,6 +1909,8 @@ async function activateProject(id) {
|
||||
state.requirements = [];
|
||||
state.cables = [];
|
||||
state.bundles = [];
|
||||
state.clamps = [];
|
||||
state.cableClamps = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
} else {
|
||||
@@ -1624,6 +1928,7 @@ function armTool(tool) {
|
||||
wrap.classList.toggle("tool-frame", tool === "frame");
|
||||
wrap.classList.toggle("tool-device", tool === "device");
|
||||
wrap.classList.toggle("tool-cable", tool === "cable");
|
||||
wrap.classList.toggle("tool-clamp", tool === "clamp");
|
||||
for (const btn of document.querySelectorAll("[data-tool]")) {
|
||||
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
|
||||
}
|
||||
@@ -1655,6 +1960,7 @@ function bindTools() {
|
||||
else if (e.key === "f" || e.key === "F") armTool("frame");
|
||||
else if (e.key === "d" || e.key === "D") armTool("device");
|
||||
else if (e.key === "i" || e.key === "I") armTool("io");
|
||||
else if (e.key === "c" || e.key === "C") armTool("clamp");
|
||||
else if (e.key === "r" || e.key === "R") armTool("req");
|
||||
else if (e.key === "s" || e.key === "S") openSolveModal();
|
||||
});
|
||||
@@ -1679,6 +1985,11 @@ let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
|
||||
// on a .cable-handle, used by renderCanvas to anchor the dragged end
|
||||
// at the cursor; cleared on pointerup (commit or cancel).
|
||||
let cableReplug = /** @type {{cableID: number, end: "from"|"to", x: number, y: number}|null} */ (null);
|
||||
// Mid-segment drag — m grabs a point on a cable's polyline (not on an
|
||||
// endpoint handle, not on an existing clamp vertex) and drags. On
|
||||
// release, either snap to a nearby clamp or create a fresh one at the
|
||||
// drop point and insert at the right `ord`.
|
||||
let cableMidDrag = /** @type {{cableID: number, segmentIdx: number, x: number, y: number}|null} */ (null);
|
||||
|
||||
function onCanvasPointerDown(e) {
|
||||
// Pan gestures win over every tool. Middle-click and Space+drag both
|
||||
@@ -1714,6 +2025,11 @@ function onCanvasPointerDown(e) {
|
||||
placeDeviceAt(p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "clamp") {
|
||||
e.preventDefault();
|
||||
placeClampAt(p, e);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "io") {
|
||||
e.preventDefault();
|
||||
placeIOMarkerAt(p);
|
||||
@@ -2105,6 +2421,121 @@ function startCableReplug(e, cableID, end) {
|
||||
svg.addEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
// Mid-segment cable drag: m grabs a point on a selected cable's
|
||||
// polyline (not on an endpoint handle) and drags. On release, snap to
|
||||
// the nearest clamp within MID_SNAP world-units, or create a fresh one
|
||||
// at the drop point. Either way, attach it to the cable at the right
|
||||
// ord so the new vertex sits inside the segment m was bending.
|
||||
const MID_SNAP_PX = 16; // visual constant — divided by current zoom
|
||||
function startCableMidDrag(e, cable, vertices) {
|
||||
if (!state.active) return;
|
||||
// Refuse if the click target is an endpoint handle — let the replug
|
||||
// handler own that gesture.
|
||||
if (e.target instanceof Element && e.target.classList.contains("cable-handle")) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||||
const start = svgPoint(e);
|
||||
// Identify which segment the click landed closest to.
|
||||
const segIdx = nearestSegmentIndex(vertices, start);
|
||||
try { svg.setPointerCapture(e.pointerId); } catch {}
|
||||
$(".canvas-wrap").classList.add("replugging");
|
||||
cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: start.x, y: start.y };
|
||||
renderCanvas();
|
||||
|
||||
const onMove = (ev) => {
|
||||
const p = svgPoint(ev);
|
||||
cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: p.x, y: p.y };
|
||||
renderCanvas();
|
||||
};
|
||||
const onUp = async (ev) => {
|
||||
svg.removeEventListener("pointermove", onMove);
|
||||
svg.removeEventListener("pointerup", onUp);
|
||||
svg.removeEventListener("pointercancel", onUp);
|
||||
try { svg.releasePointerCapture(ev.pointerId); } catch {}
|
||||
$(".canvas-wrap").classList.remove("replugging");
|
||||
const dropWorld = svgPoint(ev);
|
||||
cableMidDrag = null;
|
||||
// Cancel if the cursor barely moved (≤ a few px in world coords) —
|
||||
// m probably clicked the cable to select it, not bend it.
|
||||
if (Math.hypot(dropWorld.x - start.x, dropWorld.y - start.y) < 4) {
|
||||
renderCanvas();
|
||||
return;
|
||||
}
|
||||
// Snap radius in world coords — visual constant per design v5 §11.9 q2.
|
||||
const snapRadius = MID_SNAP_PX / state.view.zoom;
|
||||
let nearest = null;
|
||||
let bestDist = Infinity;
|
||||
for (const cl of state.clamps) {
|
||||
const d = Math.hypot(cl.x - dropWorld.x, cl.y - dropWorld.y);
|
||||
if (d < bestDist) { bestDist = d; nearest = cl; }
|
||||
}
|
||||
try {
|
||||
let clampID;
|
||||
if (nearest && bestDist <= snapRadius) {
|
||||
// Snap onto existing clamp — but only if it's not already on
|
||||
// this cable (UNIQUE constraint would 409). Skip silently in
|
||||
// that case rather than spamming an alert.
|
||||
const already = state.cableClamps.some(
|
||||
(cc) => cc.cable_id === cable.id && cc.clamp_id === nearest.id,
|
||||
);
|
||||
if (already) { renderCanvas(); return; }
|
||||
clampID = nearest.id;
|
||||
} else {
|
||||
// Fresh clamp at the drop point.
|
||||
const frame = frameAt(dropWorld.x, dropWorld.y);
|
||||
const newClamp = await createClamp(state.active.id, {
|
||||
x: dropWorld.x, y: dropWorld.y,
|
||||
frame_id: frame ? frame.id : undefined,
|
||||
});
|
||||
state.clamps.push(newClamp);
|
||||
clampID = newClamp.id;
|
||||
}
|
||||
// Insert at ord = segIdx + 1 (1-based; segmentIdx is the segment
|
||||
// between vertices[segIdx] and vertices[segIdx + 1]).
|
||||
const cc = await attachClampToCable(state.active.id, cable.id, {
|
||||
clamp_id: clampID, ord: segIdx + 1,
|
||||
});
|
||||
// Refresh cable_clamps so the new ord + any shifted neighbours
|
||||
// are reflected without a full snapshot reload.
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cableClamps = snap.cable_clamps || [];
|
||||
render();
|
||||
// Silence unused-var lint without dropping the result.
|
||||
void cc;
|
||||
} catch (err) {
|
||||
alert(`Insert clamp failed: ${err.message}`);
|
||||
renderCanvas();
|
||||
}
|
||||
};
|
||||
svg.addEventListener("pointermove", onMove);
|
||||
svg.addEventListener("pointerup", onUp);
|
||||
svg.addEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
// Index of the segment in `vertices` closest to point p. Segment i sits
|
||||
// between vertices[i] and vertices[i+1].
|
||||
function nearestSegmentIndex(vertices, p) {
|
||||
let best = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < vertices.length - 1; i++) {
|
||||
const a = vertices[i], b = vertices[i + 1];
|
||||
const d = pointSegmentDistance(p, a, b);
|
||||
if (d < bestDist) { bestDist = d; best = i; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Shortest distance from point p to the line segment a–b.
|
||||
function pointSegmentDistance(p, a, b) {
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
|
||||
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
||||
}
|
||||
|
||||
/** Port-click flow:
|
||||
* - A cable draw is in progress (cableDrawFromPortID set):
|
||||
* same port → cancel; another port → finish the cable.
|
||||
@@ -2239,6 +2670,40 @@ async function createPortFromForm(deviceID, typeID, edge, label) {
|
||||
}
|
||||
}
|
||||
|
||||
// + Clamp tool: drop a standalone routing anchor at the click. If the
|
||||
// click landed on a cable (slice 4 will detect this), the clamp will
|
||||
// also be attached to that cable mid-segment. For slice 3 we just
|
||||
// place it.
|
||||
async function placeClampAt(p, e) {
|
||||
if (!state.active) return;
|
||||
armTool(null);
|
||||
// Did the click hit a cable? If so, attach the new clamp to that cable.
|
||||
const cableEl = e && e.target instanceof Element
|
||||
? e.target.closest("[data-cable-id]")
|
||||
: null;
|
||||
const cableID = cableEl ? Number(cableEl.getAttribute("data-cable-id")) : null;
|
||||
const frame = frameAt(p.x, p.y);
|
||||
try {
|
||||
const c = await createClamp(state.active.id, {
|
||||
x: p.x, y: p.y,
|
||||
frame_id: frame ? frame.id : undefined,
|
||||
});
|
||||
state.clamps.push(c);
|
||||
if (cableID) {
|
||||
try {
|
||||
const cc = await attachClampToCable(state.active.id, cableID, { clamp_id: c.id });
|
||||
state.cableClamps.push(cc);
|
||||
} catch (ex) {
|
||||
alert(`Attach to cable failed: ${ex.message}`);
|
||||
}
|
||||
}
|
||||
state.selection = { kind: "clamp", id: c.id };
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(`Create clamp failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeIOMarkerAt(p) {
|
||||
if (!state.active) return;
|
||||
armTool(null);
|
||||
@@ -2329,19 +2794,21 @@ function startDrag(e, kind, id) {
|
||||
|
||||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||||
const start = svgPoint(e);
|
||||
/** @type {Frame|Device|IOMarker|undefined} */
|
||||
/** @type {Frame|Device|IOMarker|Clamp|undefined} */
|
||||
let obj;
|
||||
if (kind === "frame") obj = state.frames.find((f) => f.id === id);
|
||||
else if (kind === "device") obj = state.devices.find((d) => d.id === id);
|
||||
else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id);
|
||||
else if (kind === "clamp") obj = state.clamps.find((c) => c.id === id);
|
||||
if (!obj) return;
|
||||
const startX = obj.x;
|
||||
const startY = obj.y;
|
||||
|
||||
// For frame drags, remember the contained devices + IO markers + their
|
||||
// offsets so they follow the frame visually + persist on release.
|
||||
// For frame drags, remember the contained devices + IO markers + clamps
|
||||
// + their offsets so they follow the frame visually + persist on release.
|
||||
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
|
||||
let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]);
|
||||
let trackedClamps = /** @type {{c: Clamp, sx: number, sy: number}[]} */ ([]);
|
||||
if (kind === "frame") {
|
||||
for (const d of state.devices) {
|
||||
if (d.frame_id === obj.id) {
|
||||
@@ -2353,6 +2820,11 @@ function startDrag(e, kind, id) {
|
||||
trackedIOs.push({ m, sx: m.x, sy: m.y });
|
||||
}
|
||||
}
|
||||
for (const c of state.clamps) {
|
||||
if (c.frame_id === obj.id) {
|
||||
trackedClamps.push({ c, sx: c.x, sy: c.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the rect element NOW: by the time onUp fires async, the
|
||||
@@ -2376,6 +2848,7 @@ function startDrag(e, kind, id) {
|
||||
if (kind === "frame") {
|
||||
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
|
||||
for (const t of trackedIOs) { t.m.x = t.sx + dx; t.m.y = t.sy + dy; }
|
||||
for (const t of trackedClamps) { t.c.x = t.sx + dx; t.c.y = t.sy + dy; }
|
||||
}
|
||||
renderCanvas();
|
||||
};
|
||||
@@ -2392,12 +2865,14 @@ function startDrag(e, kind, id) {
|
||||
if (kind === "frame") {
|
||||
const f = /** @type {Frame} */ (obj);
|
||||
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
|
||||
// Persist contained devices + IO markers too.
|
||||
// Persist contained devices + IO markers + clamps too.
|
||||
await Promise.all([
|
||||
...trackedDevices.map((t) =>
|
||||
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
|
||||
...trackedIOs.map((t) =>
|
||||
patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })),
|
||||
...trackedClamps.map((t) =>
|
||||
patchClamp(state.active.id, t.c.id, { x: t.c.x, y: t.c.y })),
|
||||
]);
|
||||
} else if (kind === "device") {
|
||||
const d = /** @type {Device} */ (obj);
|
||||
@@ -2412,7 +2887,7 @@ function startDrag(e, kind, id) {
|
||||
d.frame_id = newFrameID;
|
||||
}
|
||||
await patchDevice(state.active.id, d.id, patchBody);
|
||||
} else /* io */ {
|
||||
} else if (kind === "io") {
|
||||
const m = /** @type {IOMarker} */ (obj);
|
||||
const cx = m.x + IO_SIZE / 2;
|
||||
const cy = m.y + IO_SIZE / 2;
|
||||
@@ -2424,6 +2899,16 @@ function startDrag(e, kind, id) {
|
||||
m.frame_id = newFrameID;
|
||||
}
|
||||
await patchIOMarker(state.active.id, m.id, patchBody);
|
||||
} else /* clamp */ {
|
||||
const c = /** @type {Clamp} */ (obj);
|
||||
const targetFrame = frameAt(c.x, c.y);
|
||||
const newFrameID = targetFrame ? targetFrame.id : null;
|
||||
const patchBody = { x: c.x, y: c.y };
|
||||
if ((c.frame_id ?? null) !== newFrameID) {
|
||||
patchBody.frame_id = newFrameID;
|
||||
c.frame_id = newFrameID;
|
||||
}
|
||||
await patchClamp(state.active.id, c.id, patchBody);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Save failed: ${err.message}`);
|
||||
|
||||
@@ -227,9 +227,44 @@ body {
|
||||
.canvas-wrap.tool-device #canvas *,
|
||||
.canvas-wrap.tool-io #canvas,
|
||||
.canvas-wrap.tool-io #canvas *,
|
||||
.canvas-wrap.tool-clamp #canvas,
|
||||
.canvas-wrap.tool-clamp #canvas *,
|
||||
.canvas-wrap.tool-cable #canvas,
|
||||
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
|
||||
|
||||
/* Clamps — small grey rounded squares (v5 §11). Cables route through
|
||||
them in `ord` sequence. */
|
||||
.clamp {
|
||||
fill: rgba(120, 120, 120, 0.85);
|
||||
stroke: rgba(40, 40, 40, 0.85);
|
||||
stroke-width: 1.5;
|
||||
cursor: grab;
|
||||
}
|
||||
.clamp.selected {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
.clamp-label {
|
||||
fill: var(--text-muted);
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Shared-segment count badge — m sees ×N next to clamps that route
|
||||
≥ 2 cables. */
|
||||
.clamp-badge {
|
||||
fill: var(--text);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Bundle overlay — thick striped polyline drawn on top of individual
|
||||
cables along shared segments. v5 §11.3. */
|
||||
.bundle-line {
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
Reference in New Issue
Block a user