Compare commits

...

22 Commits

Author SHA1 Message Date
mAi
7331f334a8 merge: frame resize handle (bottom-right corner)
Mirrors picasso's device-resize pattern. 10x10 handle at frame's
bottom-right; pointerdown→drag→PATCH width+height on release. Min
200x150. Doesn't interfere with frame-body drag or label-as-grip
selection.
2026-05-17 17:21:52 +02:00
mAi
1c234f3f46 feat(ui): bottom-right resize handle on frames
m: 'We should also be able to resize frames, the same way we do with
devices.' Mirrors the device-resize pattern (89686d0).

- 10×10 SVG handle drawn at each frame's bottom-right corner with class
  .frame-resize-handle + cursor: nwse-resize. Appended after the label
  so it sits on top of the rect and wins the pointerdown.
- startFrameResize captures the pointer, stops propagation so the
  rect's pointerdown (= startDrag 'frame') doesn't also fire, and
  updates f.width / f.height on every pointermove using svgPoint
  deltas — works at any zoom level via the same world-coord conversion
  the rest of the canvas uses.
- Clamps to 200×150 minimum during the drag (frames need more room
  than devices since they host devices + IO markers + clamps).
- On pointerup: PATCH /api/projects/:pid/frames/:id with the new width
  + height. Contained children stay at their absolute positions — the
  frame body drag is what moves them; resize only changes the frame's
  own bounds, so devices/IO markers/clamps inside don't shift.
2026-05-17 17:19:53 +02:00
mAi
cff897978f merge: frame label = clickable drag grip
Drop pointer-events:none on .frame-label and add a pointerdown that
fires startDrag(e,'frame',f.id) — gives m a deterministic select-and-
drag grip at the top-left of every frame where devices/cables can't
occlude it.
2026-05-16 19:33:26 +02:00
mAi
55f8a06560 fix(ui): frame label is a clickable drag grip
Frame rect interior is occluded by devices/cables in SVG render order, so
clicking the frame to select/drag it was unreliable. Drop pointer-events:none
from .frame-label and bind the same pointerdown→startDrag('frame',id) as the
rect — the top-left label text is now a deterministic grip.
2026-05-16 19:32:14 +02:00
mAi
79e17a5cb1 merge: rename mCables → CableGUI (full)
Gitea repo: m/mCables → m/CableGUI
Docker image: m/mcables → m/cablegui
mDock paths: /home/m/stacks/{mcables→cablegui}/ + /home/m/secrets/{mcables→cablegui}/
DB filename: data/{mcables.db → cablegui.db}
Go module + env vars (MCABLES_* → CABLEGUI_*) renamed throughout.
LOFT project survived the DB filename move.
2026-05-16 15:39:16 +02:00
mAi
c206a331ec rename: mCables → CableGUI (project + repo + image + paths)
Full project rename per m's call. Single atomic commit because the
codebase rename is a coupled change — go module path, env vars, DB
default, Docker artefact names, and on-disk mDock paths all flip
together.

- go.mod: module mgit.msbls.de/m/mcables → mgit.msbls.de/m/cablegui
- cmd/mcables → cmd/cablegui (git mv)
- All Go imports rewritten to the new module path
- Env vars: MCABLES_ADDR/MCABLES_DB → CABLEGUI_ADDR/CABLEGUI_DB
- DB default path: data/mcables.db → data/cablegui.db
- Dockerfile + docker-compose.yml: image, container_name, env vars,
  bind-mount /home/m/stacks/mcables → /home/m/stacks/cablegui,
  secrets /home/m/secrets/mcables → /home/m/secrets/cablegui
- Makefile: bin target + run/build commands point at cmd/cablegui
- .gitignore + .dockerignore: /mcables → /cablegui
- README, docs/design.md, CLAUDE.md: prose + paths + image name
- web/static/index.html: <title> + brand
- web/static/main.js + web/web.go: header comment
- internal/exporter: Scene.Source "mcables" → "cablegui"
- internal/server/export.go: error-detail secrets path
- internal/db/migrations/*.sql: header comments (mCables vN → CableGUI vN)

Memory group_id kept as "mcables" to preserve existing memory continuity.
Documented as historical in CLAUDE.md.

go build ./... clean; go test -race ./... green
2026-05-16 15:35:42 +02:00
mAi
2b4c574508 merge: left-click-drag on empty canvas pans the view 2026-05-16 14:05:56 +02:00
mAi
2933bb8662 fix(ui): left-click-drag on empty canvas pans the view
Canvas zoom shipped pan as middle-drag / Space+drag, which left m unable
to reach a freshly-created frame outside the default viewport — the
only escape was middle-button or holding Space, neither of which is
discoverable.

Empty-canvas left-pointerdown now starts an ambiguous gesture: if the
cursor moves past a 3px screen-space threshold it promotes to a pan
(Excalidraw / Figma standard); below the threshold it falls back to
the historic "click empties the selection" UX so plain clicks still
deselect. Pointerdown on a device, frame, IO marker, port, or cable
keeps routing to its own handler. Middle-drag and Space+drag pan
unchanged.
2026-05-16 14:05:46 +02:00
mAi
98fe040364 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).
2026-05-16 14:04:37 +02:00
mAi
813d59b068 feat(v5 slice 6): export clamps + cable mid-vertices to mxdrw
Excalidraw scene now mirrors the v5 routing model:

- Clamps export as 12×12 grey rounded squares (BackgroundColor=#888888,
  StrokeColor=#555555, Roundness type 3). Distinct from the red IO
  marker diamonds so wall outlets vs. routing anchors stay readable.
  Frame_id propagates into the element's FrameID per the existing
  pattern.
- Cable arrows include clamp positions as mid-vertices in the
  `points` array. Pre-grouped + sort.Slice-sorted by ord; each
  mid-vertex is added as an (x-fromAnchor.x, y-fromAnchor.y) offset.
  startBinding / endBinding still point at the from / to endpoint
  excalidraw_ids; mid-vertices are unbound (Excalidraw doesn't have
  per-vertex binding).
- IDAssignment grows a Clamps map; PersistExcalidrawIDs accepts it
  and updates clamps.excalidraw_id on first export so re-exports
  reuse the same element ids (collab cursors / undo history survive).
- Bundle-stripe overlay is **viewer-only** — Excalidraw can't
  represent gradient strokes losslessly, so we export individual
  cable arrows and let the in-app viewer derive the bundle viz.

Tests:
- TestBuildScene_ClampsRenderAsRectangles — 2 clamps → 2 rectangle
  elements + 2 ids in IDAssignment.Clamps.
- TestBuildScene_ArrowPointsIncludeClamps — cable with 1 clamp →
  arrow.Points has 3 entries; middle vertex equals the clamp's
  position relative to fromAnchor.

This closes the v5 slice plan (§11.10). Six slices, one branch,
one redeploy below.
2026-05-16 13:58:32 +02:00
mAi
2cbefd3146 feat(v5 slice 5): shared-segment bundle viz + clamp count badges
Walks every cable's polyline, keys each vertex by stable identity
(port:N / device:N / io:N / clamp:N), and accumulates cables by
undirected segment-key. Segments with ≥ 2 cables get a thick striped
overlay line in a new <g id="canvas-bundles"> layer, drawn on top of
the individual cable lines so the shared portion reads as a bundle
while endpoints still fan out to each cable's port colour.

- Stripe width: 2 + N px, capped at 12 (design v5 §11.3).
- Stripe order: by distinct cable-type count (ties by id) per
  v5 §11.9 q4.
- Implementation: SVG <linearGradient> with hard stops oriented
  perpendicular to the segment, registered in a new
  <defs id="canvas-defs"> on every render. Bundle <line> uses
  stroke="url(#bundle-grad-…)".
- <title> child lists the cable types and total cable count for
  hover tooltips.
- Clamp render gains a ×N badge when ≥ 2 cables route through it,
  derived independently from state.cableClamps.

Helper rename: cableVertices → cableVerticesWithKeys (returns
{vertices, keys}). The keys array also feeds the shared-segment
detection — keeps the geometry + identity tracking in one pass.
2026-05-16 13:54:57 +02:00
mAi
a1de1246e5 merge: remove '+ Type' button from sidebar legend
Per m: cable-type creation lives in the admin modal; the sidebar
button was prominent for a rare action.
2026-05-16 13:52:08 +02:00
mAi
fee9bc5d26 feat(ui): remove '+ Type' button from sidebar legend
Cable type creation is managed via the admin modal (⚙ → Cable types
tab), which makes the prominent sidebar affordance unnecessary. Drop
the button element and its click handler; the legend itself (rows,
edit button per row, active-type selection) is unchanged.
2026-05-16 13:50:49 +02:00
mAi
04e7e86a52 feat(v5 slice 4): cable polyline through clamps + mid-segment drag
Cables now render as <polyline> through their cable_clamps in `ord`
sequence. Empty clamp set collapses to a straight from→to line, so
nothing visual changes for unrouted (auto-emitted) cables.

cableVertices(cable, …) resolves the endpoint anchors + each clamp's
(x, y) into the vertex array. Endpoint-replug handles continue to
operate on the first/last vertex.

Mid-segment drag — startCableMidDrag:
- Triggered by pointerdown on a *selected* cable's polyline (button=0,
  not on an endpoint handle, no Space pan).
- nearestSegmentIndex + pointSegmentDistance pick which segment m is
  bending. The dragged vertex is rendered as a temp inserted point in
  the cable's polyline via a module-level cableMidDrag preview.
- On release: snap to the nearest existing clamp within
  MID_SNAP_PX / zoom (visual constant per design v5 §11.9 q2), else
  POST a fresh clamp at the drop point. Either way, attach to the
  cable at ord = segIdx + 1 so the new vertex sits inside the segment
  m was bending. A tiny-motion (< 4 world-units) drop is treated as
  a plain click-to-select and cancelled.

Snapping to a clamp already on the cable is a no-op (UNIQUE constraint
would 409). Re-fetches cable_clamps from the snapshot after each
attach so ord shifts from the slice-1 attach helper propagate.
2026-05-16 13:50:44 +02:00
mAi
6af076a5e0 feat(v5 slice 3): clamp render + Place tool + inspector
Frontend hooks for the v5 routing primitive.

- state gains clamps + cableClamps arrays, hydrated from the snapshot
  (`clamps`, `cable_clamps`). Reset on null-project + project-404 paths.
- API helpers: createClamp / patchClamp / deleteClamp + attach / detach /
  reorder cable_clamps.
- +Clamp tool button + "C" keyboard shortcut. armTool flips the
  tool-clamp class on .canvas-wrap (crosshair cursor).
- onCanvasPointerDown routes tool === "clamp" to placeClampAt, which
  POSTs a clamp at the click position. If the click target is on a
  cable, the new clamp is also attached to that cable in one go.
- renderCanvas paints clamps as 12×12 rounded squares (per design v5
  §11.9 q1) in a new #canvas-clamps <g>. Drag uses the existing
  startDrag pipeline (kind="clamp"), which now also moves clamps when
  their containing frame is dragged.
- renderInspectorClamp shows label + position + cables-through list +
  Delete (with cascade confirm when shared).

Slice 4 wires the clamp into a cable's polyline (mid-segment drag,
visual routing); for now placing a clamp on top of a cable just
attaches it.
2026-05-16 13:48:07 +02:00
mAi
ae59dfc894 feat(v5 slice 2): clamp HTTP endpoints
Wire the v5 store helpers from slice 1 onto net/http routes:

  GET    /api/projects/:pid/clamps
  POST   /api/projects/:pid/clamps
  PATCH  /api/projects/:pid/clamps/:id
  DELETE /api/projects/:pid/clamps/:id

  POST   /api/projects/:pid/cables/:cid/clamps          — attach
  PUT    /api/projects/:pid/cables/:cid/clamps          — reorder
  DELETE /api/projects/:pid/cables/:cid/clamps/:cmid    — detach

frame_id uses the same json.RawMessage tri-state as device/io patches
(absent / null / int) via the existing parseFrameRef helper.

Snapshot endpoint (GET /api/projects/:id) now carries the clamps[] +
cable_clamps[] arrays surfaced by ListClamps + ListCableClamps in
slice 1, so the frontend gets everything in one round-trip.
2026-05-16 13:42:23 +02:00
mAi
4202d0465f feat(v5 slice 1): clamps schema + store helpers + snapshot
Migration 007 introduces the v5 routing primitive:
- clamps table (project-scoped, optional frame_id, excalidraw_id).
- cable_clamps join (cable_id, clamp_id, ord) with PK on (cable_id, ord)
  and UNIQUE (cable_id, clamp_id) to block a clamp visiting the same
  cable twice.

Store helpers in internal/db/clamps.go:
- CreateClamp / GetClamp / ListClamps / UpdateClamp / DeleteClamp —
  standard project-scoped CRUD. UpdateClamp uses FrameRef tri-state.
- AttachClampToCable — appends or inserts at a given ord. Mid-sequence
  inserts use a two-pass shift (bump by 10000, settle to ord+1) since
  SQLite UPDATE doesn't support ORDER BY and a single bulk +1 would
  collide with the UNIQUE (cable_id, ord) PK.
- DetachClampFromCable — removes the row then closes the gap.
- ReorderCableClamps — replaces the whole sequence in one tx.
- ListClampsForCable / ListCableClamps — read helpers.

Snapshot now carries clamps + cable_clamps arrays so the frontend can
hydrate everything in one call.

Tests cover create / update / cascade-delete / attach (append + insert
+ duplicate-rejected) / detach (gap closes) / reorder / snapshot.
2026-05-16 13:40:53 +02:00
mAi
8df5de193a merge: fix overbroad gitignore matching cmd/mcables/
Bare 'mcables' pattern in .gitignore + .dockerignore matched cmd/mcables/
in addition to the built binary at repo root. Root-anchored to '/mcables'.
cmd/mcables/main.go now tracked in git. Fresh worktrees / clones build
clean without copying main.go from a sibling.
2026-05-16 13:39:16 +02:00
mAi
a675c499c3 fix: root-anchor mcables ignore pattern, commit cmd/mcables/main.go
The bare `mcables` pattern in .gitignore (line 11) and .dockerignore
(line 18) was intended to ignore the built binary at the repo root, but
without a leading slash it also matched the cmd/mcables/ directory. The
result: cmd/mcables/main.go was never tracked in git, and fresh worktrees
had to copy it from a sibling to build.

- Change `mcables` → `/mcables` in both files (still ignores the root
  binary; no longer matches the cmd subdirectory).
- Add cmd/mcables/main.go (copied from picasso's worktree, verified
  identical to head's main checkout).

Verified: `git check-ignore cmd/mcables/main.go` returns not-ignored;
a touched `./mcables` at the repo root is still ignored via `/mcables`.
`go build ./...` clean.
2026-05-16 13:38:52 +02:00
mAi
78bce498b4 merge: design v5 — cable routing via clamps (§11)
Schema (clamps + cable_clamps join), polyline-through-clamps rendering,
bundle = derived from shared-segment overlap (no detection algorithm),
clamp tool + drag-cable-midpoint-to-snap-through-clamp UX, export
maps to Excalidraw arrow mid-points.
2026-05-16 13:35:14 +02:00
mAi
359ed892ac merge: double-click port → start cable draw (dali's variant)
Adds armTool('cable') so the cursor shows crosshair during the
in-progress draw — matches m's literal 'cursor crosshair' request.

(Picasso shipped a similar fix in parallel due to a head dispatch
race; dropping picasso's variant in favour of this one.)
2026-05-16 13:29:58 +02:00
mAi
fca9fb0a0f design(v5): cable routing via clamps — §11
m's bundling primitive: a clamp is a physical anchor on the canvas;
cables route through clamps in order; cables that share a consecutive
clamp pair are visibly bundled on that segment. Overlap is the bundle —
no detection pass.

Section covers:
- 11.1 Schema: clamps table + cable_clamps join, migration 007. Clamps
  carry frame_id so frame-drag carries them.
- 11.2 Cable rendering: <polyline> through [from, clamp₁..n, to];
  endpoint-replug handles stay on first/last vertices.
- 11.3 Bundle visualisation: shared segments rendered as a 2+N px
  striped line; clamp icon shows ×N count when shared. Computed live
  on every renderCanvas — O(C·N̄), trivial at v0 scale.
- 11.4 UI: +Clamp tool (C shortcut), mid-segment drag-to-snap (snap
  radius ~16 px / zoom), clamp inspector, right-click remove-from-cable.
- 11.5 Existing bundles table: keep, repurpose. Implicit bundles are
  derived from shared clamp segments; explicit named bundles still live
  in the table.
- 11.6 Solver coupling: v0 solver still emits straight cables; m
  hand-routes after. v5.1 future work for solver-suggested clamps.
- 11.7 Export: clamps export as small grey diamonds; cable arrows use
  Excalidraw's points array for mid-vertices. Bundle stripes are
  viewer-only (Excalidraw can't represent them losslessly).
- 11.8 API additions: clamp CRUD, attach/detach/reorder cable clamps.
  Snapshot grows clamps + cable_clamps arrays.
- 11.9 Five open questions for m (icon shape, snap radius scaling,
  cascade-on-delete confirm, stripe order, solver respect for manual
  clamp routing).
- 11.10 6-step slice plan post-approval.

DESIGN v5 READY FOR REVIEW
2026-05-16 13:19:55 +02:00
40 changed files with 2046 additions and 172 deletions

View File

@@ -15,7 +15,7 @@ data
# Build artefacts
bin
mcables
/cablegui
# Editor cruft
.vscode

2
.gitignore vendored
View File

@@ -8,7 +8,7 @@ data/*.db-shm
# Build artefacts
bin/
mcables
/cablegui
# Editor
.vscode/

View File

@@ -1,11 +1,11 @@
# mCables — Project Instructions
# CableGUI — Project Instructions
## Project Overview
Cable-management **framework + solver** for m's setup. m declares his
**devices** and the **connection requirements** between them ("NAS must
connect to Switch via RJ45"). mCables runs a solver that emits the cable
plan + bundle recommendations. mCables is a **schematic**, not a
connect to Switch via RJ45"). CableGUI runs a solver that emits the cable
plan + bundle recommendations. CableGUI is a **schematic**, not a
physical-routing tool — cables are straight lines between endpoints; the
"maximum bundling" objective is satisfied by the endpoint-pair rule
(when two or more cables share the same A↔B endpoint pair, group them
@@ -13,16 +13,17 @@ into one bundle). The visual editor is still there for tweaking the
plan, but the solver is the headline.
Each cable-managed environment (LOFT, OFFICE, …) is a separate
**mCables project**, and each project is backed by exactly one Excalidraw
**CableGUI project**, and each project is backed by exactly one Excalidraw
drawing. The framework provides a visual web interface backed by a Go
HTTP API and SQLite, plus an export pipeline that writes `.excalidraw`
files via mExDraw.
**Memory group_id:** `mcables`
**Memory group_id:** `mcables` (kept historical — all prior memories live
under this id; renaming would orphan them)
**No CLI.** Frontend-first — every interaction is through the visual
interface. The backend serves the UI and the API; there is no
`mcables` shell binary intended for humans.
`cablegui` shell binary intended for humans.
## Goal
@@ -53,7 +54,7 @@ interface. The backend serves the UI and the API; there is no
| Layer | Tech | Notes |
|---|---|---|
| DB | SQLite | `./data/mcables.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). |
| DB | SQLite | `./data/cablegui.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). |
| Backend | Go | `net/http` HTTP API + static frontend via `embed.FS`. Standard library + minimal deps. Single binary. |
| Frontend | Vanilla JS modules + SVG, no build step | TypeScript types via JSDoc, optional `tsc --noEmit` in CI. Preact-via-CDN-ESM is the documented fallback if vanilla state gets painful — no build step either way. |
| Diagram I/O | mExDraw HTTP API | `PUT https://mxdrw.msbls.de/api/drawings/<name>.excalidraw` with `Authorization: Bearer $MEXDRAW_TOKEN`. (The `mcp__mexdraw__*` MCP tools are not currently configured for this project — workers use the raw HTTP API.) |
@@ -112,14 +113,14 @@ interface. The backend serves the UI and the API; there is no
## Deployment — mDock, raw docker (NOT Dokploy)
mCables runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`)
CableGUI runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`)
as a **plain docker-compose service**. Dokploy is for public mlake/mRiver
stuff; mDock uses raw `docker compose` per the conventions of the existing
mDock services (mgreen, mgeo, msports-garmin, paperless, …).
- Repo layout on mDock: `/home/m/stacks/mcables/` with `docker-compose.yml`,
`data/` bind-mount, secrets in `/home/m/secrets/mcables/.env`.
- Image: `mgit.msbls.de/m/mcables:latest` (built and pushed by a Gitea
- Repo layout on mDock: `/home/m/stacks/cablegui/` with `docker-compose.yml`,
`data/` bind-mount, secrets in `/home/m/secrets/cablegui/.env`.
- Image: `mgit.msbls.de/m/cablegui:latest` (built and pushed by a Gitea
Actions workflow on push to `main`, runs on the self-hosted runner on
mDock with label `self-hosted:host`).
- Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy.
@@ -127,12 +128,12 @@ mDock services (mgreen, mgeo, msports-garmin, paperless, …).
- LAN URL: `http://mdock:7777`.
- No auth — LAN-trusted.
Local dev (no Docker): `go run ./cmd/mcables` against `./data/mcables.db`.
Local dev (no Docker): `go run ./cmd/cablegui` against `./data/cablegui.db`.
## Seed drawing — visual grammar reference, **not** a runtime importer
`mxdrw.msbls.de/draw/Cable-Management.excalidraw` is **reference material
only**. mCables does **not** auto-ingest it. m will rebuild LOFT and OFFICE
only**. CableGUI does **not** auto-ingest it. m will rebuild LOFT and OFFICE
from scratch inside the tool — the seed exists so the **exporter** mimics
its visual grammar:
@@ -163,13 +164,13 @@ Legend colours (global, seeded once by migration 001):
## Out of scope (v0)
- Multi-user. mCables is m-only.
- Multi-user. CableGUI is m-only.
- Auth / sharing — LAN-trusted on mDock.
- Mobile / responsive — desktop browser only.
- Cable inventory beyond visual structure (no length, no purchase history,
no SKU). Strictly visual structure for v0.
- Import from `.excalidraw` at runtime. If a one-shot migration is ever
needed, a separate `mcables-migrate` CLI tool is the right shape, not a
needed, a separate `cablegui-migrate` CLI tool is the right shape, not a
hot API endpoint.
## Worker Preferences

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
#
# mCables — single-stage build → distroless runtime image.
# CableGUI — single-stage build → distroless runtime image.
# go.mod requires go 1.25; modernc.org/sqlite is pure Go so CGO_ENABLED=0
# and a distroless/static runtime is all we need.
@@ -17,20 +17,20 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w" \
-o /out/mcables \
./cmd/mcables
-o /out/cablegui \
./cmd/cablegui
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/mcables /app/mcables
COPY --from=build /out/cablegui /app/cablegui
ENV MCABLES_ADDR=0.0.0.0:7777 \
MCABLES_DB=/app/data/mcables.db
ENV CABLEGUI_ADDR=0.0.0.0:7777 \
CABLEGUI_DB=/app/data/cablegui.db
EXPOSE 7777
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
# /home/m/stacks/mcables/data is owned by m:m, so the container can write
# /home/m/stacks/cablegui/data is owned by m:m, so the container can write
# to it without chowning the host dir. distroless/static-debian12 accepts
# arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry.
USER 1000:1000
ENTRYPOINT ["/app/mcables"]
ENTRYPOINT ["/app/cablegui"]

View File

@@ -1,14 +1,14 @@
.PHONY: build run test typecheck fmt clean
BIN := bin/mcables
BIN := bin/cablegui
PKG := ./...
build:
@mkdir -p bin
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/mcables
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/cablegui
run:
go run ./cmd/mcables
go run ./cmd/cablegui
test:
go test -race $(PKG)

View File

@@ -1,9 +1,9 @@
# mCables
# CableGUI
Cable-management **framework** for m's setup — visual web editor backed by
a single Go binary + SQLite, generating Excalidraw drawings via mExDraw.
Each cable-managed environment (LOFT, OFFICE, …) is a separate mCables
Each cable-managed environment (LOFT, OFFICE, …) is a separate CableGUI
*project*; each project is backed by exactly one `.excalidraw` drawing on
mxdrw.msbls.de.
@@ -23,7 +23,7 @@ end-to-end; the SVG canvas is intentionally empty until slice 2.
## Run it
```sh
go run ./cmd/mcables
go run ./cmd/cablegui
# open http://localhost:7777
```
@@ -31,18 +31,18 @@ Or built:
```sh
make build
./bin/mcables
./bin/cablegui
```
The binary serves the frontend from an embedded `web/static/` and the
JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default.
JSON API under `/api/`. SQLite lives at `./data/cablegui.db` by default.
### Environment
| Var | Default | Notes |
|---|---|---|
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
| `CABLEGUI_ADDR` | `0.0.0.0:7777` | Listen address. |
| `CABLEGUI_DB` | `./data/cablegui.db` | SQLite path. Parent dir is created on boot. |
| `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. |
| `MEXDRAW_USER` | (unset) | Username for the mxdrw HTTP Basic Auth on export. Required. |
| `MEXDRAW_PASS` | (unset) | Password for the mxdrw HTTP Basic Auth on export. Required. |
@@ -78,37 +78,35 @@ DELETE /api/cable-types/:id ← 409 in_use if any cable references
## Deploy to mDock
mCables runs on **mDock** at `http://mdock:7777` as a docker-compose
service under `/home/m/stacks/mcables/`. Pattern matches the other
CableGUI runs on **mDock** at `http://mdock:7777` as a docker-compose
service under `/home/m/stacks/cablegui/`. Pattern matches the other
mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy,
no reverse proxy, LAN-trusted.
### Manual deploy (first roll)
### Manual deploy
1. **Build + push the image** (from any host with docker; today the
image lives in mAi's Gitea namespace because mAi doesn't have write
access to `m/`):
1. **Build + push the image** (image now lives under `m/` in Gitea):
```sh
docker build -t mgit.msbls.de/mai/mcables:latest .
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc-mai \
| docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/mai/mcables:latest
docker build -t mgit.msbls.de/m/cablegui:latest .
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc \
| docker login mgit.msbls.de -u m --password-stdin
docker push mgit.msbls.de/m/cablegui:latest
```
2. **Prepare directories on mDock** (one-time):
```sh
ssh mdock 'mkdir -p /home/m/stacks/mcables/data /home/m/secrets/mcables \
&& touch /home/m/secrets/mcables/.env \
&& chmod 0600 /home/m/secrets/mcables/.env'
scp docker-compose.yml mdock:/home/m/stacks/mcables/docker-compose.yml
ssh mdock 'mkdir -p /home/m/stacks/cablegui/data /home/m/secrets/cablegui \
&& touch /home/m/secrets/cablegui/.env \
&& chmod 0600 /home/m/secrets/cablegui/.env'
scp docker-compose.yml mdock:/home/m/stacks/cablegui/docker-compose.yml
```
3. **Pull + start**:
```sh
ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'
ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'
```
4. **Verify** from any LAN host:
@@ -119,11 +117,11 @@ no reverse proxy, LAN-trusted.
```
To **update** to a new build: rebuild + push the image, then
`ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'`.
`ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'`.
### Persistence
SQLite lives at `/home/m/stacks/mcables/data/mcables.db` on the host
SQLite lives at `/home/m/stacks/cablegui/data/cablegui.db` on the host
(bind-mounted into the container at `/app/data`). Container runs as
UID 1000:1000 to align with `m:m` ownership on mDock — DB files end
up owned by `m`, the host user.

64
cmd/cablegui/main.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"mgit.msbls.de/m/cablegui/internal/db"
"mgit.msbls.de/m/cablegui/internal/server"
"mgit.msbls.de/m/cablegui/web"
)
func main() {
addr := envOr("CABLEGUI_ADDR", "0.0.0.0:7777")
dbPath := envOr("CABLEGUI_DB", "./data/cablegui.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
log.Fatalf("mkdir data dir: %v", err)
}
store, err := db.Open(dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer store.Close()
if err := db.Migrate(store.DB()); err != nil {
log.Fatalf("migrate: %v", err)
}
srv := &http.Server{
Addr: addr,
Handler: server.New(store, web.Static()),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("cablegui listening on %s (db=%s)", addr, dbPath)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Printf("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -1,20 +1,20 @@
# mCables — production compose for mDock.
# Lives at /home/m/stacks/mcables/docker-compose.yml on mDock.
# CableGUI — production compose for mDock.
# Lives at /home/m/stacks/cablegui/docker-compose.yml on mDock.
# Matches the existing mDock service patterns (mgreen, mgeo, …).
services:
mcables:
image: mgit.msbls.de/m/mcables:latest
container_name: mcables
cablegui:
image: mgit.msbls.de/m/cablegui:latest
container_name: cablegui
restart: unless-stopped
ports:
- "7777:7777"
environment:
- TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db
- CABLEGUI_ADDR=0.0.0.0:7777
- CABLEGUI_DB=/app/data/cablegui.db
env_file:
# MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
- /home/m/secrets/mcables/.env
- /home/m/secrets/cablegui/.env
volumes:
- /home/m/stacks/mcables/data:/app/data
- /home/m/stacks/cablegui/data:/app/data

View File

@@ -1,4 +1,4 @@
# mCables — Design v4.1
# CableGUI — Design v4.1
Cable-management **framework + solver** for m's setup. Inventor shift 1
design, revised through v2 (rescope to multi-project framework), v3
@@ -6,7 +6,7 @@ design, revised through v2 (rescope to multi-project framework), v3
**v4.1 — six locked answers from m's v4 review**.
> **What changed in v4.1** (tight pass on v4)
> 1. **mCables is a schematic, not a physical-routing tool.** Cables are
> 1. **CableGUI is a schematic, not a physical-routing tool.** Cables are
> straight lines between endpoints; the solver and the renderer do not
> care about paths, trunks, frame edges, or cable-tray polylines.
> "Maximum bundling" reduces to the v3 rule: **≥2 cables between the
@@ -33,13 +33,13 @@ design, revised through v2 (rescope to multi-project framework), v3
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
the *visual-grammar reference*, not a bootstrap import target),
`mai-memory` (`mcables`, `m`), and the live mDock services for deploy
`mai-memory` (`cablegui`, `m`), and the live mDock services for deploy
conventions (§10). v4 driven by m's product-vision clarification:
> "we provide a cable manager — I say what devices we have, the app tells
> me how to bundle cables and how the most efficient connection looks like"
mCables shifts from a manual draw-and-click editor to a **solver** that
CableGUI shifts from a manual draw-and-click editor to a **solver** that
takes a list of devices + the connections m needs and emits the cable
plan + bundle recommendations. The manual editor stays (it's the only way
to inspect + tweak the plan) but is no longer the primary surface.
@@ -60,7 +60,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
> without applying; default applies.
> - **Solver objective: maximum bundling** (§5b.1). Schematic only: when
> two or more cables share the same endpoint pair, group them into one
> bundle. No path or trunk geometry — mCables is a wiring schematic,
> bundle. No path or trunk geometry — CableGUI is a wiring schematic,
> not a routing tool. v4.1 strips all path/trunk language from the v4
> draft.
> - **UI: device-type dropdown** on device-create, **Connection
@@ -72,7 +72,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
> bundle-rendering polish.
>
> **What carried over from v3 (unchanged in v4)**
> - mCables is a framework: top-level `projects`, each backed by one
> - CableGUI is a framework: top-level `projects`, each backed by one
> `.excalidraw` drawing. `UNIQUE(projects.name)`.
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
@@ -81,8 +81,8 @@ to inspect + tweak the plan) but is no longer the primary surface.
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
> - No cable inventory metadata; visual + connectivity structure only.
> - DB at `./data/mcables.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
> - Deploy on mDock under `/home/m/stacks/mcables/`, raw docker-compose.
> - DB at `./data/cablegui.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
> - Deploy on mDock under `/home/m/stacks/cablegui/`, raw docker-compose.
>
> **What's superseded in v4**
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
@@ -97,7 +97,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
`Cable-Management.excalidraw` on mxdrw.msbls.de is **not** ingested at
runtime. It is the visual-grammar reference we lock the export onto so that
when m rebuilds LOFT and OFFICE inside mCables, the exported `.excalidraw`
when m rebuilds LOFT and OFFICE inside CableGUI, the exported `.excalidraw`
looks like the seed.
Concrete numbers from the live file (180 elements):
@@ -128,7 +128,7 @@ Three observations about the seed's visual grammar — these constrain the
1. **Ports sit on a device edge as small ellipses (~12×9)**, coloured by
cable type. They are not children of the device in the Excalidraw sense
(no `containerId`/`boundElements` link) — purely positional. When we
export from mCables we mimic that: port ellipse at `(device.x +
export from CableGUI we mimic that: port ellipse at `(device.x +
port.x_offset, device.y + port.y_offset)`, stroke colour = type colour.
2. **Cable arrows bind to elements**. In the seed: 44 endpoints to ellipses
(ports), 12 to whole rectangles (device-level, no specific port), 3 to
@@ -161,7 +161,7 @@ painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
## 2. SQLite schema
`./data/mcables.db` (project-local, gitignored). WAL mode, FKs on.
`./data/cablegui.db` (project-local, gitignored). WAL mode, FKs on.
Driver: **`modernc.org/sqlite`** (cgo-free — clean cross-compile, simple
Dockerfile).
@@ -609,8 +609,8 @@ cascade does **not** touch `cable_types` (no FK to projects).
## 3. Go HTTP API
Single binary `cmd/mcables`, `net/http`, no router framework. Listens on
`0.0.0.0:7777` by default (overridable via `MCABLES_ADDR`). Static frontend
Single binary `cmd/cablegui`, `net/http`, no router framework. Listens on
`0.0.0.0:7777` by default (overridable via `CABLEGUI_ADDR`). Static frontend
from `embed.FS` at `/`, JSON API under `/api/`.
```
@@ -780,7 +780,7 @@ generated scene JSON.
## 4. Export — DB → Excalidraw (visual-grammar conformance)
mCables generates a `.excalidraw` scene from a project's rows. The seed
CableGUI generates a `.excalidraw` scene from a project's rows. The seed
drawing's grammar is the contract.
### 4.1 Element mapping
@@ -798,7 +798,7 @@ drawing's grammar is the contract.
### 4.2 Element IDs are stable across exports
Every mCables row carries `excalidraw_id` (TEXT, generated on first export
Every CableGUI row carries `excalidraw_id` (TEXT, generated on first export
via `crypto/rand` → 21-char Excalidraw-style ID). On re-export the same row
reuses the same ID. This means:
@@ -866,7 +866,7 @@ left strictly alone — the solver only adds and removes its own.
### 5b.1 Objective: maximum bundling — schematic only
mCables is a **schematic**, not a physical-routing tool. Cables are
CableGUI is a **schematic**, not a physical-routing tool. Cables are
straight lines between endpoints; the solver has no model of walls,
floors, cable trays, or path geometry. "Maximum bundling" therefore
reduces to a single rule on the schematic:
@@ -988,7 +988,7 @@ triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
```
┌─────────────────────┐
mCables DB (truth) │
│ CableGUI DB (truth) │
└──────────┬──────────┘
export ▼
@@ -998,11 +998,11 @@ triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
└────────────────────────┘
```
- mCables UI → DB: synchronous (every drag/add/remove persists immediately).
- CableGUI UI → DB: synchronous (every drag/add/remove persists immediately).
- DB → Excalidraw: **manual** button "Export to Excalidraw" in the header,
per project. Calls `POST /api/projects/:pid/sync/export`.
- Excalidraw → DB: **not implemented** in v0. Anything m draws in
Excalidraw stays in Excalidraw until he redraws it in mCables.
Excalidraw stays in Excalidraw until he redraws it in CableGUI.
This keeps the v0 scope tight: no conflict resolution, no element-diff
import, no auto-debounce. mExDraw keeps its own version history (git
@@ -1012,7 +1012,7 @@ When mxdrw is unreachable: the export button shows a tooltip and disables;
the editor keeps working against the local DB.
Post-MVP, import returns as a one-shot migration tool (separate
`mcables-migrate` CLI tool, not part of the running server) for seeding
`cablegui-migrate` CLI tool, not part of the running server) for seeding
new projects from existing `.excalidraw` files.
---
@@ -1023,7 +1023,7 @@ The editor lives at `/`. Layout:
```
┌────────────────────────────────────────────────────────────────────┐
mCables [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
│ CableGUI [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
├────────┬───────────────────────────────────────────────────────────┤
│ │ │
│ Legend │ │
@@ -1254,7 +1254,7 @@ Slices 9+ (not promised for the first coder shift):
- Cable inventory metadata (length/SKU) if m later wants it.
- Dark mode.
Out of scope, period (would change mCables's mental model): path
Out of scope, period (would change CableGUI's mental model): path
routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
3D, anything that treats a cable as more than a labelled endpoint pair.
@@ -1264,7 +1264,7 @@ routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
The six v4 questions are now answered. Locked answers:
1. **Where do paths come from?** → **Nowhere — mCables is a schematic.**
1. **Where do paths come from?** → **Nowhere — CableGUI is a schematic.**
Cables are straight lines between endpoints. The solver does not
route, the renderer does not route, and "maximum bundling" reduces to
the endpoint-pair rule (§5b.1). Anything resembling a path, trunk,
@@ -1307,25 +1307,25 @@ before writing this:
- Host port mappings: deliberately collision-free across the host. Existing
high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878
(radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr).
**Port 7777 is free** — taking it for mCables.
**Port 7777 is free** — taking it for CableGUI.
- Bind-mount volumes: `/home/m/<project>-data:/app/data` is the canonical
pattern (mgreen). For project-local data we put `data/` *next to* the
compose file so a `git pull && docker compose up -d` is the whole deploy:
`/home/m/stacks/mcables/data:/app/data`.
`/home/m/stacks/cablegui/data:/app/data`.
- Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin
pattern). mCables only needs `MEXDRAW_TOKEN` for export.
pattern). CableGUI only needs `MEXDRAW_TOKEN` for export.
- No reverse proxy on mDock. Services expose ports directly on the LAN
(mDock = `192.168.178.131` / Tailscale `mdock`). Public exposure goes via
mlake/Dokploy + Caddy when needed — out of scope for mCables (LAN-only).
mlake/Dokploy + Caddy when needed — out of scope for CableGUI (LAN-only).
- Auto-deploy via the Gitea Actions self-hosted runner already installed
on mDock (`/home/m/act-runner/`, label `self-hosted:host`). Push to
`main` → workflow on mDock → `docker compose up --build -d`.
### Repo layout for mCables
### Repo layout for CableGUI
```
mCables/
├── cmd/mcables/main.go # Go binary
CableGUI/
├── cmd/cablegui/main.go # Go binary
├── internal/
│ ├── db/ # migrations + store
│ ├── importer/ # post-MVP only (not in MVP)
@@ -1336,7 +1336,7 @@ mCables/
│ ├── main.js # ES module entry
│ ├── style.css
│ └── lib/... # SVG helpers, store, components
├── data/ # mCables runtime DB lives here (gitignored)
├── data/ # CableGUI runtime DB lives here (gitignored)
│ └── .gitkeep
├── docs/design.md # this file
├── Dockerfile
@@ -1361,37 +1361,37 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/mcables ./cmd/mcables
-o /out/cablegui ./cmd/cablegui
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/mcables /app/mcables
ENV MCABLES_ADDR=0.0.0.0:7777
ENV MCABLES_DB=/app/data/mcables.db
COPY --from=build /out/cablegui /app/cablegui
ENV CABLEGUI_ADDR=0.0.0.0:7777
ENV CABLEGUI_DB=/app/data/cablegui.db
USER nonroot:nonroot
EXPOSE 7777
ENTRYPOINT ["/app/mcables"]
ENTRYPOINT ["/app/cablegui"]
```
### docker-compose.yml (on mDock at `/home/m/stacks/mcables/`)
### docker-compose.yml (on mDock at `/home/m/stacks/cablegui/`)
```yaml
services:
mcables:
image: mgit.msbls.de/m/mcables:latest
container_name: mcables
cablegui:
image: mgit.msbls.de/m/cablegui:latest
container_name: cablegui
restart: unless-stopped
ports:
- "7777:7777"
environment:
- TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db
- CABLEGUI_ADDR=0.0.0.0:7777
- CABLEGUI_DB=/app/data/cablegui.db
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de
env_file:
- /home/m/secrets/mcables/.env # contains MEXDRAW_TOKEN
- /home/m/secrets/cablegui/.env # contains MEXDRAW_TOKEN
volumes:
- /home/m/stacks/mcables/data:/app/data
- /home/m/stacks/cablegui/data:/app/data
```
LAN URL: `http://mdock:7777` (or `http://192.168.178.131:7777`).
@@ -1412,15 +1412,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t mgit.msbls.de/m/mcables:latest .
run: docker build -t mgit.msbls.de/m/cablegui:latest .
- name: Push image
run: |
echo "${{ secrets.GITEA_TOKEN }}" | \
docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/m/mcables:latest
docker push mgit.msbls.de/m/cablegui:latest
- name: Up
run: |
cd /home/m/stacks/mcables
cd /home/m/stacks/cablegui
docker compose pull
docker compose up -d
```
@@ -1428,7 +1428,7 @@ jobs:
### Local-development run (no Docker)
```
make run # go run ./cmd/mcables → :7777 against ./data/mcables.db
make run # go run ./cmd/cablegui → :7777 against ./data/cablegui.db
make typecheck # tsc --noEmit on web/
make test # go test ./...
```
@@ -1438,4 +1438,228 @@ gitignored.
---
DESIGN v4.1 READY FOR REVIEW
## 11. v5 — Cable routing via clamps
m's bundling primitive: a **clamp** is a physical anchor on the canvas
(think cable tie / clip). A cable routes from its `from` endpoint,
through zero or more clamps **in order**, to its `to` endpoint. Two
cables that share an ordered pair of consecutive clamps are visibly
bundled along that segment — no detection pass, no inference: the
overlap *is* the bundle.
This replaces the abandoned waypoints + segment-detection approach.
v0's straight-line schematic stays as the empty-clamps case
(`cable_clamps` is empty for a fresh solver-emitted cable).
### 11.1 Schema (migration 007)
```sql
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 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..N along from→to
PRIMARY KEY (cable_id, ord),
UNIQUE (cable_id, clamp_id) -- a cable can't visit the same clamp twice
);
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);
```
`frame_id` on clamps mirrors devices + IO markers — m can put a clamp
inside a frame and the frame-drag carries it.
`UNIQUE (cable_id, clamp_id)` blocks loops. `ord` is a small int, 1-based;
nothing requires it to be contiguous (m can renumber 1, 2, 3 → 1, 3, 5
during edits and the renderer is fine with that), but the UI keeps them
contiguous on every mutation for sanity.
### 11.2 Cable rendering model
Each cable resolves to a polyline `[from-anchor, clamp₁, clamp₂, …, clampₙ, to-anchor]`
where:
- `from-anchor` / `to-anchor` come from the existing `anchorForEndpoint`
resolver (port / device / IO).
- clamp anchors are `(clamp.x, clamp.y)` directly — clamps don't have a
width/height to centre.
For N=0 clamps the result is the v0 straight line. For N≥1 we render
a `<polyline>` instead of a `<line>`.
The endpoint-replug handles from §10 (cable-replug) stay on the **first
and last** vertices. Mid-polyline vertices get their own clamp-handle —
small grab points only on the selected cable, which behave like
clamp-detach when dragged onto empty canvas (drop a clamp off the
cable's path).
### 11.3 Bundle visualisation — derived from shared segments
A **segment** is a directed pair `(A, B)` where A and B are consecutive
nodes of a cable's polyline. Two cables share a segment when their
polyline contains the same A→B (or B→A — segment matching is
undirected).
For each segment, compute `cables[]` — the cables that traverse it.
If `len(cables) ≥ 2`, render the segment as a single thick line on top
of the individual ones:
- **Width**: `2 + N` px (N = cable count). Caps at ~12 px.
- **Colour**: a striped pattern, one stripe per distinct cable type in
the bundle, ordered by cable_type.id. SVG `<linearGradient>` with
hard stops produces the stripe band cheaply; render it on a sibling
`<polyline>` over the individual lines.
- **Tooltip**: `<title>` child listing the cables ("Power · USB · HDMI").
At a clamp where ≥ 2 cables meet, the clamp icon (10×10 rounded square)
shows a small count badge (`×N`) when N > 1. At fan-out points
(endpoint with no clamp before it on the polyline) the individual
coloured lines re-emerge, so m sees which port each strand goes to.
Shared-segment computation is O(C·N̄) where C = #cables and N̄ = average
polyline length. For a v0-sized project (≤ ~30 cables, ≤ ~5 clamps per
cable) this is trivial. We rebuild the segment map on every renderCanvas
— no caching layer.
### 11.4 UI gestures
**+ Clamp tool (`C` shortcut, also a sidebar button):**
- Click empty canvas → place a clamp at the cursor (POST `/clamps`).
Standalone clamp — not on any cable yet.
- Click a cable line → insert this clamp into that cable. The new clamp
sits at the click position (snapped to the nearest point on the
cable's polyline) and its `ord` is computed so it falls between the
two existing vertices it lies between.
**Drag a cable's mid-segment:**
- Pointerdown on a cable line (not on an endpoint handle) and drag.
Live preview shows a bend at the cursor. Pointerup:
- If the cursor is within snap-radius (~16 px) of an existing clamp:
insert that clamp into the cable's polyline at the right `ord`.
- Otherwise: create a fresh clamp at the release point and insert it.
**Clamp inspector** (selecting a clamp on the canvas):
- Position (x, y editable + label)
- "Cables through this clamp": list with each cable's two endpoints,
click → select that cable
- "Remove from this cable" (per row) → DELETE the matching cable_clamps
row; cable's polyline collapses around the gap.
- "Delete clamp" → cascade-removes from every cable_clamps row.
**Right-click on a clamp icon ON a cable** → "Remove from this cable"
inline.
**Frame drag** carries clamps the same way it carries devices + IO
markers (clamp.frame_id mirrors the existing pattern, drag handler
already iterates frame-contained items).
### 11.5 Relationship to the existing `bundles` table
**Recommendation: keep `bundles` and `bundle_cables`, repurpose them.**
- Implicit/auto bundles → derived live from shared clamp segments. No
DB rows. The §5 `GET /bundles/suggestions` endpoint stays useful as a
"you might want to route these through the same clamps" hint.
- Explicit named bundles → still in the `bundles` table. m names a
group ("desk → wall trunk"), the UI offers "route all members through
these clamps" as a one-click action. Useful for the case where m
wants a stable label on a logical bundle that isn't yet routed.
Migration 007 leaves `bundles` + `bundle_cables` untouched. A v6 cleanup
can drop them if m decides the explicit-named path isn't worth keeping.
### 11.6 Solver coupling
The v0 solver still emits **straight cables** — no clamp rows. m
hand-routes after Solve. The solver's preview-diff is unaffected
(solver compares endpoint pairs; clamp routing is independent of the
endpoint identity).
Future v5.1: solver-suggested clamps based on shared paths between
endpoint pairs. Out of scope here.
### 11.7 Export to mxdrw
Clamps map to small diamond elements (separate from IO markers — IO
diamonds are red wall-outlets; clamps are grey routing points).
`excalidraw_id` is stable across re-exports per the existing pattern.
Cable arrows become Excalidraw `arrow` elements with mid-points (the
clamp positions) when N≥1 — Excalidraw supports multi-vertex arrows
via the `points` array. Each `startBinding` / `endBinding` resolves to
the from/to anchor's excalidraw_id; mid-vertices are unbound.
Bundle visualisation (thick striped lines on shared segments) is **not
exported** in v0 — Excalidraw doesn't natively support gradient strokes,
and the mxdrw round-trip would lose them. We export each cable as its
own polyline; bundling is a viewer-only concept.
### 11.8 API additions
```
POST /api/projects/:pid/clamps { x, y, label?, frame_id? } → Clamp
PATCH /api/projects/:pid/clamps/:id { x?, y?, label?, frame_id? } → Clamp
DELETE /api/projects/:pid/clamps/:id
POST /api/projects/:pid/cables/:cid/clamps { clamp_id, ord? } → CableClamp
DELETE /api/projects/:pid/cables/:cid/clamps/:cmid
# Convenience: re-order clamps on a cable in one call
PUT /api/projects/:pid/cables/:cid/clamps { clamp_ids: [int, int, …] }
```
Snapshot endpoint grows two arrays:
- `clamps: []Clamp`
- `cable_clamps: []{ cable_id, clamp_id, ord }`
### 11.9 Open questions for m
1. **Clamp icon shape.** Diamond (overlaps visually with IO markers
when zoomed out), small filled circle (overlaps with port circles),
or rounded square `` 10×10? Recommend rounded square — distinct from
everything else on the canvas today.
2. **Snap radius when inserting onto a cable.** ~16 px world-units feels
right at 1× zoom. Should it scale with zoom (visual constant) or stay
world-constant (gesture stays the same regardless of zoom)? Recommend
visual constant — divide by current zoom.
3. **Clamp deletion when shared.** If a clamp is used by 4 cables and m
clicks "Delete clamp", do we (a) refuse with a "still in use" prompt,
(b) cascade-remove from all 4 cables, or (c) cascade silently? Current
draft says cascade silently. Worth a confirmation?
4. **Bundle stripe order.** Cable-type id is stable but arbitrary; visual
order on a thick line affects readability. Order by stripe-count
(Power first if 3 Power + 1 USB), or by cable-type-id (deterministic
but unrelated to importance)? Recommend by-count, ties broken by id.
5. **Solver respect for existing routing.** When m re-runs Solve after
hand-routing, should the solver preserve existing clamp routing on
user-owned (`auto=0`) cables? Auto cables are wiped + rebuilt, so
their clamps disappear with them — that's expected. But manual cables
with clamps should clearly keep them. Confirm.
### 11.10 Slice plan (post-design)
1. Schema migration + tx-aware store helpers (Create/Update/DeleteClamp,
AttachClampToCable, DetachClampFromCable, ReorderClamps).
2. HTTP endpoints + snapshot extension.
3. Frontend: clamp render + + Clamp tool + canvas placement (no
cable attach yet).
4. Cable polyline render via clamps, mid-segment drag-to-clamp,
clamp inspector.
5. Shared-segment bundle visualisation (gradient stripe + count badge).
6. Export pipeline extension — mxdrw arrows with mid-points + clamp
diamonds. Bundle viz stays viewer-only.
---
DESIGN v5 READY FOR REVIEW

2
go.mod
View File

@@ -1,4 +1,4 @@
module mgit.msbls.de/m/mcables
module mgit.msbls.de/m/cablegui
go 1.25.5

351
internal/db/clamps.go Normal file
View 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
View 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))
}
}

View File

@@ -1,4 +1,4 @@
// Package db owns SQLite access for mCables: migrations runner + the
// Package db owns SQLite access for CableGUI: migrations runner + the
// query layer (store.go). The Store wraps a *sql.DB with helpers; tests
// and the HTTP layer take a *Store, never a raw *sql.DB.
package db

View File

@@ -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()
}

View File

@@ -1,4 +1,4 @@
-- mCables v3 initial schema. See docs/design.md §2.
-- CableGUI v3 initial schema. See docs/design.md §2.
-- A project IS a drawing. LOFT and OFFICE are separate projects.
-- One project ↔ one .excalidraw file in mExDraw.

View File

@@ -1,4 +1,4 @@
-- mCables v4 device-type catalog. See docs/design.md §2.1 + §2.2.
-- CableGUI v4 device-type catalog. See docs/design.md §2.1 + §2.2.
-- v4 — device-type catalog. Built-in types live globally (project_id NULL).
-- Per-project custom types use project_id = X.

View File

@@ -1,4 +1,4 @@
-- mCables v4.1 connection requirements + solver-owned cable flag.
-- CableGUI v4.1 connection requirements + solver-owned cable flag.
-- See docs/design.md §2.1 + §2 connection_requirements + §5b.3.
-- The solver's input: "device A must connect to device B via cable type T".

View File

@@ -1,4 +1,4 @@
-- mCables v4.1 setup templates. See docs/design.md §2.4.
-- CableGUI v4.1 setup templates. See docs/design.md §2.4.
--
-- A template is a named recipe of (device_types + requirements) that
-- bootstraps a project from blank to solver-ready in one apply call.

View File

@@ -1,4 +1,4 @@
-- mCables v5 — catalog: power-distribution devices.
-- CableGUI v5 — catalog: power-distribution devices.
-- Adds 5 built-in device_types (project_id NULL, built_in=1).
--
-- Multi-plug N exposes Power × (N+1) ports — one input + N outputs. The

View File

@@ -1,4 +1,4 @@
-- mCables v6 — fix IOx-* and Multi-plug-* + Wifi-plug port profiles.
-- CableGUI v6 — fix IOx-* and Multi-plug-* + Wifi-plug port profiles.
--
-- v4 seeded the IOx-3 / IOx-6 / IOx-8 as USB hubs (Power × 1 + USB × N),
-- but m's physical IOx-* devices are power strips (1 power input on

View File

@@ -0,0 +1,31 @@
-- CableGUI 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);

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -11,8 +11,9 @@ import (
"encoding/json"
"fmt"
"math/big"
"sort"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
// Scene is the top-level Excalidraw file format. Keys mirror what the
@@ -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),
@@ -470,7 +537,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
scene := &Scene{
Type: "excalidraw",
Version: 2,
Source: "mcables",
Source: "cablegui",
Elements: els,
AppState: AppState{
GridSize: nil,

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
// deterministic id generator for tests
@@ -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())

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
type cableEndpointBody struct {

195
internal/server/clamps.go Normal file
View File

@@ -0,0 +1,195 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/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)
}

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
type connReqCreate struct {

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
type deviceTypePortBody struct {

View File

@@ -12,8 +12,8 @@ import (
"strings"
"time"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/mcables/internal/exporter"
"mgit.msbls.de/m/cablegui/internal/db"
"mgit.msbls.de/m/cablegui/internal/exporter"
)
// syncExport runs the project's snapshot through the exporter, persists
@@ -34,7 +34,7 @@ func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
if user == "" || pass == "" {
writeJSON(w, http.StatusBadRequest, errorBody{
Error: "MEXDRAW_USER / MEXDRAW_PASS not set",
Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/mcables/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth",
Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/cablegui/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth",
})
return
}
@@ -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
}

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
// ---------------------------------------------------------------- frames

View File

@@ -6,7 +6,7 @@ import (
"net/http"
"strconv"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
type handlers struct {

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
type ioMarkerCreate struct {

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
type portCreate struct {

View File

@@ -7,10 +7,10 @@ import (
"io/fs"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
// New returns an http.Handler serving the mCables API at /api/ and the
// New returns an http.Handler serving the CableGUI API at /api/ and the
// embedded frontend at /. The frontend FS should be rooted such that
// "index.html" is at its root.
func New(store *db.Store, frontend fs.FS) http.Handler {
@@ -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

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
func (h *handlers) solve(w http.ResponseWriter, r *http.Request) {

View File

@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mCables</title>
<title>CableGUI</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header class="topbar">
<span class="brand">mCables</span>
<span class="brand">CableGUI</span>
<div class="project-picker">
<label for="project-select" class="sr-only">Project</label>
<select id="project-select" aria-label="Active project">
@@ -36,7 +36,6 @@
<section class="legend">
<h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
@@ -44,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>
@@ -52,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">

View File

@@ -1,4 +1,4 @@
// mCables frontend entry — vanilla ES module, no build step.
// CableGUI frontend entry — vanilla ES module, no build step.
//
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
// inline naming, inspector for selection. State stays minimal: one
@@ -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));
@@ -258,6 +271,52 @@ function startPan(e) {
svg.addEventListener("pointercancel", onUp);
}
// Left-click on empty canvas: ambiguous between "deselect" and "pan".
// We resolve by movement — under the drag threshold m gets the historic
// "click empties the selection" behaviour; past the threshold the gesture
// promotes to a pan (Excalidraw / Figma standard). 3px screen-space dead
// zone is enough that a steady click doesn't accidentally nudge the view.
const EMPTY_CANVAS_PAN_THRESHOLD_PX = 3;
function startEmptyCanvasGesture(e) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const ctm = svg.getScreenCTM();
if (!ctm) return;
const scaleX = ctm.a, scaleY = ctm.d;
const startClientX = e.clientX, startClientY = e.clientY;
const startViewX = state.view.x, startViewY = state.view.y;
let panning = false;
try { svg.setPointerCapture(e.pointerId); } catch {}
const onMove = (ev) => {
const dx = ev.clientX - startClientX;
const dy = ev.clientY - startClientY;
if (!panning) {
if (Math.hypot(dx, dy) <= EMPTY_CANVAS_PAN_THRESHOLD_PX) return;
panning = true;
$(".canvas-wrap").classList.add("panning");
}
state.view.x = startViewX - dx / scaleX;
state.view.y = startViewY - dy / scaleY;
applyViewBox();
};
const onUp = (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
if (panning) {
$(".canvas-wrap").classList.remove("panning");
setViewInURL();
} else if (state.selection) {
state.selection = null;
render();
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
function resetView() {
state.view.zoom = 1;
state.view.x = 0;
@@ -393,11 +452,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 });
@@ -415,8 +480,24 @@ function renderCanvas() {
});
label.textContent = f.name;
g.append(rect, label);
// Bottom-right resize handle. Mirrors the device pattern — sits on
// top of the rect so its pointerdown wins, with stopPropagation in
// startFrameResize blocking the rect's startDrag underneath.
const FHSZ = 10;
const fHandle = svgEl("rect", {
x: f.x + f.width - FHSZ,
y: f.y + f.height - FHSZ,
width: FHSZ, height: FHSZ,
class: "frame-resize-handle",
"data-frame-id": f.id,
});
fHandle.addEventListener("pointerdown", (e) => startFrameResize(e, f.id));
g.append(fHandle);
gFrames.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
label.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
}
const portsByDevice = new Map();
@@ -539,30 +620,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 +724,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 +751,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 +882,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 +1471,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 +1928,8 @@ async function activateProject(id) {
state.requirements = [];
state.cables = [];
state.bundles = [];
state.clamps = [];
state.cableClamps = [];
state.selection = null;
setActiveInURL(null);
render();
@@ -1584,6 +1946,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 +1971,8 @@ async function activateProject(id) {
state.requirements = [];
state.cables = [];
state.bundles = [];
state.clamps = [];
state.cableClamps = [];
setActiveInURL(null);
render();
} else {
@@ -1624,6 +1990,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 +2022,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 +2047,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,18 +2087,29 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
if (state.tool === "clamp") {
e.preventDefault();
placeClampAt(p, e);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
return;
}
// No tool armed: clicks that started on a device/frame/io go to their
// own handlers (drag / select). Leave them alone.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id]")) return;
// No tool armed: clicks that started on a device/frame/io/clamp/port/cable
// go to their own handlers (drag / select / replug). Leave them alone.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id], [data-clamp-id], [data-port-id], [data-cable-id]")) return;
// Plain canvas click = clear selection.
if (state.selection) { state.selection = null; render(); }
// Empty-canvas left-click without an active cable draw: start a
// maybe-pan gesture. It promotes to a pan once the cursor crosses the
// drag threshold; if m clicks without dragging it falls back to the
// historic "clear selection" UX. Other buttons fall through (middle is
// already handled above, right-click is the browser context menu).
if (e.button === 0 && state.cableDrawFromPortID == null) {
startEmptyCanvasGesture(e);
}
}
function startFrameRubberBand(e, p0) {
@@ -2016,6 +2400,51 @@ function startResize(e, deviceID) {
svg.addEventListener("pointercancel", onUp);
}
// Frame bottom-right resize gesture. Mirrors startResize for devices,
// but PATCHes /frames/:id and uses a larger minimum (frames host
// devices + IO markers + clamps, so 200×150 is the smallest useful
// canvas). Contained children stay at their absolute positions — the
// frame body drag is what moves them; resize only changes the frame's
// own bounds.
function startFrameResize(e, frameID) {
if (!state.active) return;
// Hard-stop so the rect's pointerdown doesn't also fire startDrag.
e.stopPropagation();
e.preventDefault();
const f = state.frames.find((x) => x.id === frameID);
if (!f) return;
const startWidth = f.width, startHeight = f.height;
const startWorld = svgPoint(e);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
try { svg.setPointerCapture(e.pointerId); } catch {}
const MIN_FRAME_W = 200, MIN_FRAME_H = 150;
const onMove = (ev) => {
const p = svgPoint(ev);
f.width = Math.max(MIN_FRAME_W, startWidth + (p.x - startWorld.x));
f.height = Math.max(MIN_FRAME_H, startHeight + (p.y - startWorld.y));
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
if (f.width === startWidth && f.height === startHeight) return;
try {
const updated = await patchFrame(state.active.id, f.id, {
width: f.width, height: f.height,
});
Object.assign(f, updated);
renderCanvas();
} catch (err) {
alert(`Resize failed: ${err.message}`);
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
// Find the topmost canvas element under (clientX, clientY) that maps to
// a cable endpoint target. Returns { kind, id } for port / device / IO,
// or null when m dropped on empty canvas.
@@ -2105,6 +2534,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 ab.
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 +2783,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 +2907,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 +2933,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 +2961,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 +2978,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 +3000,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 +3012,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}`);
@@ -3224,7 +3822,6 @@ async function boot() {
bindCloseButtons($("#modal-admin"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#btn-admin").addEventListener("click", openAdminModal);
$("#btn-solve").addEventListener("click", openSolveModal);

View File

@@ -180,7 +180,20 @@ body {
fill: var(--accent);
font-size: 13px;
font-weight: 600;
pointer-events: none;
cursor: grab;
}
/* Frame bottom-right resize affordance. Mirrors .device-resize-handle
but uses the accent-on-frame palette so it reads as part of the frame
chrome rather than the device. */
.frame-resize-handle {
fill: rgba(0, 0, 0, 0.15);
stroke: rgba(0, 0, 0, 0.25);
stroke-width: 1;
cursor: nwse-resize;
}
.frame-resize-handle:hover {
fill: rgba(0, 0, 0, 0.3);
}
/* Stroke + fill come from the device's user-set colour, written as
@@ -227,9 +240,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;

View File

@@ -1,5 +1,5 @@
// Package web bundles the frontend (HTML/JS/CSS) into the Go binary
// via embed.FS so deploying mCables means shipping one file.
// via embed.FS so deploying CableGUI means shipping one file.
package web
import (