Compare commits

..

12 Commits

Author SHA1 Message Date
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
0ecd9c8b4a feat(ui): double-click a port to start a cable draw
Double-click a port → enter cable-draw mode from that port without
having to arm the cable tool first. armTool("cable") is called so
the crosshair cursor is active during the draw; the next port-click
hits the existing cable-draw-in-progress branch in onPortPointerDown
and commits the cable. Esc / clicking the source port cancels.

Single-click behaviour (select + open port inspector) is unchanged
because pointerdown still hits onPortPointerDown first; dblclick
upgrades the selection to a cable-draw source.
2026-05-16 13:29:02 +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
mAi
40ab3d2630 merge: drag-to-replug cable endpoints
Selected cable shows two endpoint handles (r=7, coloured + halo).
pointerdown on a handle starts an endpoint drag; hitTestEndpointTarget
resolves cursor over port / device / IO marker; pointerup PATCHes the
from_/to_ field. Cancel on empty canvas or same-endpoint drop.
auto=1 cables auto-promote to auto=0 when m successfully drops on a
new valid endpoint.
2026-05-16 13:17:25 +02:00
mAi
17e6b5e91c feat(ui): cable endpoint replug — drag handles to a new target
m can grab either end of a selected cable and drop it on a different
port / device / IO marker. Mechanics:

- Selected cable renders two .cable-handle circles at its endpoints
  (handle radius 7, filled in the cable's colour with a white halo +
  drop-shadow). Hidden unless the cable is selected so unrelated cables
  don't litter the canvas with grab points.
- pointerdown on a handle calls startCableReplug; the module-level
  cableReplug = {cableID, end, x, y} drives renderCanvas to anchor the
  affected endpoint at the cursor in world coords. Pointermove keeps
  the line tracking; pointerup hit-tests the cursor via
  elementsFromPoint (skipping the cable-handle itself).
- Drop target:
    port   → PATCH {from|to: {port_id}}
    device → PATCH {from|to: {device_id}}
    IO     → PATCH {from|to: {io_id}}
    empty / same endpoint → cancel (no PATCH)
- When the cable was auto=1 and the drop commits, the PATCH also sends
  promote=true so the server flips it to manual — m took control.
- preventDefault + stopPropagation on the handle pointerdown so canvas
  panning / cable-line clicks don't interfere. Pointer capture survives
  the drag leaving the SVG bounds.

CSS: .cable-handle gets grab cursor + drop-shadow; .replugging on the
canvas-wrap promotes to grabbing during the gesture.
2026-05-16 13:11:33 +02:00
mAi
9107a9f7b2 merge: device resize handle (bottom-right corner)
10x10 handle on every device, drag to resize. Min 60x30. On pointerup,
PATCH width/height + relayoutAllEdges so ports re-distribute. stopPropagation
keeps the body drag separate from the handle drag. Works at any zoom.
2026-05-16 13:07:31 +02:00
mAi
89686d0c1f feat(ui): bottom-right resize handle on devices
m: 'I want the size of devices to be customizable. A resize function at
the bottom right corner would be good.'

- 10×10 SVG handle drawn at each device's bottom-right corner with class
  .device-resize-handle + cursor: nwse-resize. Subtle grey by default,
  darker on hover so m can find it without it dominating the rect.
- startResize captures the pointer, stops propagation so the rect's
  pointerdown (= startDrag) doesn't also fire, and updates the local
  device.width / .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 60×30 minimum during the drag so the rect can't collapse.
- On pointerup: PATCH /devices/:id with the new width + height, then
  relayoutAllEdges(deviceID) so ports on every edge redistribute to
  their i/(N+1) positions against the new dimensions. Right- and
  bottom-edge ports get the visible adjustment; top/left re-space too
  but their absolute positions don't change.
2026-05-16 12:59:51 +02:00
mAi
57a9154f18 merge: canvas zoom + pan (last of 6 polish tasks)
state.view = {x,y,zoom} drives SVG viewBox. Zoom clamped 0.2-5x.
- Wheel = zoom around cursor (Excalidraw-style)
- Middle-drag or Space+drag = pan
- 0 or Home = reset
- Header: zoom % indicator + Fit button (bbox + 40px padding)
- URL persists ?z=&px=&py= (cleaned when at default)
- All inputs/hit-tests stay in world coords — no changes needed to
  port/cable/drag handlers
2026-05-16 12:10:28 +02:00
mAi
6c31802522 feat(ui): canvas zoom + pan via SVG viewBox
m: wheel to zoom around the cursor, drag with middle-mouse / Space-held
to pan, `0` or `Home` to reset, Fit button to frame all content.

Implementation:
- state.view = { x, y, zoom } drives the SVG viewBox via applyViewBox().
  Base canvas is 2000×1500; viewBox = (view.x, view.y, 2000/zoom, 1500/zoom).
- Zoom clamped to 0.2x..5x. wheelZoom captures the cursor's world coord
  before + after the zoom-step and shifts view.x/y so it stays under
  the cursor (Excalidraw-style cursor-anchored zoom).
- startPan captures screen→world scale from getScreenCTM at pointerdown
  and converts pointer-move deltas into view.x/y updates — robust across
  zoom levels. Triggered by middle-mouse OR Space+drag. Releases pointer
  capture + persists the view on pointerup.
- resetView (0 / Home) restores zoom=1, x=0, y=0.
- fitToContent walks frames + devices + IO markers, computes their bbox
  with 40px padding, picks zoom = min(BASE_W/bw, BASE_H/bh), and centres
  the bbox inside the viewBox (compensating for aspect-ratio meet).
- Header gets a "100%" zoom indicator + Fit button. URL persists view
  as ?z=1.200&px=…&py=… so reload returns to the same view.

Because everything goes through viewBox (not CSS transform), svgPoint
still maps screen pixels to world coords via getScreenCTM. Existing
hit-tests, drag, port/cable placement all keep working unchanged.
2026-05-16 12:05:24 +02:00
mAi
46e8474c2b merge: requirements UX — per-device primary + all-view in admin
Device inspector gains a Requirements section + Requirement button
pre-filled with the current device's id. The global Requirements
section is removed from the left sidebar — legend + tools reclaim
the space. All-requirements view moves into the admin modal as a
5th tab.
2026-05-16 12:00:32 +02:00
7 changed files with 711 additions and 7 deletions

View File

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

2
.gitignore vendored
View File

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

64
cmd/mcables/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/mcables/internal/db"
"mgit.msbls.de/m/mcables/internal/server"
"mgit.msbls.de/m/mcables/web"
)
func main() {
addr := envOr("MCABLES_ADDR", "0.0.0.0:7777")
dbPath := envOr("MCABLES_DB", "./data/mcables.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("mcables 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

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

View File

@@ -24,6 +24,10 @@
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn">Export</button>
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
<span class="zoom-cluster">
<span id="zoom-pct" title="Zoom — scroll on canvas, or 0/Home to reset">100%</span>
<button type="button" id="btn-fit" class="btn btn-tiny" title="Fit content to view">Fit</button>
</span>
<span id="toast" class="toast" hidden></span>
</header>

View File

@@ -58,6 +58,10 @@ const state = {
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | "cable" | null */
tool: /** @type {string|null} */ (null),
/** Canvas viewport — drives the SVG viewBox. */
view: { x: 0, y: 0, zoom: 1 },
/** Space-key held → next pointerdown anywhere on canvas starts a pan. */
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,
@@ -164,6 +168,137 @@ function setActiveInURL(id) {
history.replaceState(null, "", url.toString());
}
// ---------- canvas view (zoom + pan) ---------- //
const BASE_W = 2000, BASE_H = 1500;
const ZOOM_MIN = 0.2, ZOOM_MAX = 5;
function clampZoom(z) { return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); }
function applyViewBox() {
const z = state.view.zoom;
const vw = BASE_W / z;
const vh = BASE_H / z;
$("#canvas").setAttribute("viewBox", `${state.view.x} ${state.view.y} ${vw} ${vh}`);
}
function updateZoomUI() {
const el = $("#zoom-pct");
if (el) el.textContent = `${Math.round(state.view.zoom * 100)}%`;
}
function viewFromURL() {
const p = new URLSearchParams(location.search);
const z = parseFloat(p.get("z"));
const px = parseFloat(p.get("px"));
const py = parseFloat(p.get("py"));
if (Number.isFinite(z) && z > 0) state.view.zoom = clampZoom(z);
if (Number.isFinite(px)) state.view.x = px;
if (Number.isFinite(py)) state.view.y = py;
}
function setViewInURL() {
const url = new URL(location.href);
const isDefault = state.view.zoom === 1 && state.view.x === 0 && state.view.y === 0;
if (isDefault) {
url.searchParams.delete("z");
url.searchParams.delete("px");
url.searchParams.delete("py");
} else {
url.searchParams.set("z", state.view.zoom.toFixed(3));
url.searchParams.set("px", state.view.x.toFixed(1));
url.searchParams.set("py", state.view.y.toFixed(1));
}
history.replaceState(null, "", url.toString());
}
function wheelZoom(e) {
e.preventDefault();
const before = svgPoint(e);
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = clampZoom(state.view.zoom * factor);
if (newZoom === state.view.zoom) return;
state.view.zoom = newZoom;
applyViewBox();
const after = svgPoint(e); // recomputed against the new viewBox
state.view.x += before.x - after.x;
state.view.y += before.y - after.y;
applyViewBox();
updateZoomUI();
setViewInURL();
}
function startPan(e) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const ctm = svg.getScreenCTM();
if (!ctm) return;
e.preventDefault();
e.stopPropagation();
$(".canvas-wrap").classList.add("panning");
// ctm.a / ctm.d are the world→screen scales. world delta = screen delta / scale.
const scaleX = ctm.a, scaleY = ctm.d;
const startClientX = e.clientX, startClientY = e.clientY;
const startViewX = state.view.x, startViewY = state.view.y;
try { svg.setPointerCapture(e.pointerId); } catch {}
const onMove = (ev) => {
state.view.x = startViewX - (ev.clientX - startClientX) / scaleX;
state.view.y = startViewY - (ev.clientY - startClientY) / scaleY;
applyViewBox();
};
const onUp = (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
$(".canvas-wrap").classList.remove("panning");
setViewInURL();
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
function resetView() {
state.view.zoom = 1;
state.view.x = 0;
state.view.y = 0;
applyViewBox();
updateZoomUI();
setViewInURL();
}
// Compute the bbox of every frame + device + IO marker in the current
// project and frame it into the view with a small padding. Falls back
// to reset when the project is empty.
function fitToContent() {
if (!state.active) return resetView();
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let any = false;
const cover = (x, y, w, h) => {
any = true;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
};
for (const f of state.frames) cover(f.x, f.y, f.width, f.height);
for (const d of state.devices) cover(d.x, d.y, d.width, d.height);
for (const m of state.ioMarkers) cover(m.x, m.y, IO_SIZE, IO_SIZE);
if (!any) return resetView();
const pad = 40;
minX -= pad; minY -= pad; maxX += pad; maxY += pad;
const bw = maxX - minX, bh = maxY - minY;
const zoom = clampZoom(Math.min(BASE_W / bw, BASE_H / bh));
const vw = BASE_W / zoom, vh = BASE_H / zoom;
// Centre the bbox inside the (potentially larger) viewBox.
state.view.zoom = zoom;
state.view.x = minX - (vw - bw) / 2;
state.view.y = minY - (vh - bh) / 2;
applyViewBox();
updateZoomUI();
setViewInURL();
}
// ---------- geometry ---------- //
/** Returns the smallest frame whose bbox contains (x, y), or null. */
@@ -336,9 +471,35 @@ function renderCanvas() {
});
// Port-click drives both cable-draw (slice 7) and port-select (this fix).
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
// Double-click activates cable-draw mode from this port without arming
// the cable tool first. armTool("cable") gives the crosshair cursor;
// the next port-click is then caught by onPortPointerDown's
// cable-draw-in-progress branch and commits the cable.
c.addEventListener("dblclick", (e) => {
e.stopPropagation();
e.preventDefault();
if (state.tool !== "cable") armTool("cable");
state.cableDrawFromPortID = prt.id;
state.selection = null;
render();
});
g.append(c);
}
// Bottom-right resize handle. Drawn last so it sits on top of the rect
// and any port circles that might overlap the corner. Visible always
// but subtle; cursor signals resize affordance.
const HSZ = 10;
const handle = svgEl("rect", {
x: d.x + d.width - HSZ,
y: d.y + d.height - HSZ,
width: HSZ, height: HSZ,
class: "device-resize-handle",
"data-device-id": d.id,
});
handle.addEventListener("pointerdown", (e) => startResize(e, d.id));
g.append(handle);
gDevices.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
}
@@ -385,14 +546,22 @@ function renderCanvas() {
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
for (const c of state.cables) {
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);
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;
// Replug preview: while m drags an endpoint handle, override the
// affected end with the live cursor world position so the line
// tracks the pointer.
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 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,
class: "cable-line" + (c.auto ? " auto" : "") + (state.selection?.kind === "cable" && state.selection.id === c.id ? " selected" : ""),
class: "cable-line" + (c.auto ? " auto" : "") + (isSelected ? " selected" : ""),
stroke: color,
"data-cable-id": c.id,
});
@@ -402,6 +571,23 @@ function renderCanvas() {
render();
});
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.
if (isSelected) {
for (const end of ["from", "to"]) {
const a = end === "from" ? fromAnchor : toAnchor;
const h = svgEl("circle", {
cx: a.x, cy: a.y, r: 7,
class: "cable-handle",
fill: color,
stroke: "#fff",
"data-cable-id": c.id,
"data-end": end,
});
h.addEventListener("pointerdown", (e) => startCableReplug(e, c.id, end));
gCables.append(h);
}
}
}
}
@@ -1455,22 +1641,53 @@ function bindTools() {
// Avoid stealing keys while user is typing into an input.
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === " " && !state.spaceHeld) {
// Hold Space to enable click-and-drag pan. Don't preventDefault here
// so pressing Space in unrelated focusable elements still works; the
// canvas pointerdown handler reads state.spaceHeld to gate the pan.
state.spaceHeld = true;
$(".canvas-wrap").classList.add("space-pan-ready");
e.preventDefault();
return;
}
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); }
else if (e.key === "0" || e.key === "Home") resetView();
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 === "r" || e.key === "R") armTool("req");
else if (e.key === "s" || e.key === "S") openSolveModal();
});
document.addEventListener("keyup", (e) => {
if (e.key === " ") {
state.spaceHeld = false;
$(".canvas-wrap").classList.remove("space-pan-ready");
}
});
// Canvas-level pointerdown handles tool activation + selection clearing.
$("#canvas").addEventListener("pointerdown", onCanvasPointerDown);
const svg = $("#canvas");
svg.addEventListener("pointerdown", onCanvasPointerDown);
// Wheel zooms around the cursor — `passive: false` so we can
// preventDefault and stop the page from scrolling.
svg.addEventListener("wheel", wheelZoom, { passive: false });
}
let rubberBand = /** @type {SVGRectElement|null} */ (null);
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
// Live state for a cable-endpoint replug drag. Captured at pointerdown
// 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);
function onCanvasPointerDown(e) {
// Pan gestures win over every tool. Middle-click and Space+drag both
// route here regardless of project state — m can pan an empty canvas
// without selecting a project first.
if (e.button === 1 || state.spaceHeld) {
startPan(e);
return;
}
if (!state.active) return;
const p = svgPoint(e);
@@ -1741,6 +1958,153 @@ async function relayoutEdge(deviceID, edge) {
}
}
// Re-space ports on every edge of `deviceID`. Used after the device's
// width / height change so all four edges recompute the i/(N+1)
// positions against the new dimensions.
async function relayoutAllEdges(deviceID) {
await Promise.all([
relayoutEdge(deviceID, "top"),
relayoutEdge(deviceID, "right"),
relayoutEdge(deviceID, "bottom"),
relayoutEdge(deviceID, "left"),
]);
}
// Bottom-right resize handle gesture. Updates width / height in local
// state on each move (renderCanvas redraws the rect + ports), clamps to
// a minimum so the device can't collapse, then PATCHes the new size on
// pointerup and re-spaces every edge's ports.
function startResize(e, deviceID) {
if (!state.active) return;
// Hard-stop so the rect's pointerdown doesn't also fire startDrag.
e.stopPropagation();
e.preventDefault();
const d = state.devices.find((x) => x.id === deviceID);
if (!d) return;
const startWidth = d.width, startHeight = d.height;
const startWorld = svgPoint(e);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
try { svg.setPointerCapture(e.pointerId); } catch {}
const MIN_W = 60, MIN_H = 30;
const onMove = (ev) => {
const p = svgPoint(ev);
d.width = Math.max(MIN_W, startWidth + (p.x - startWorld.x));
d.height = Math.max(MIN_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 (d.width === startWidth && d.height === startHeight) return;
try {
const updated = await patchDevice(state.active.id, d.id, {
width: d.width, height: d.height,
});
Object.assign(d, updated);
// Ports may have been on an edge that just moved (right or bottom)
// — re-distribute everything to the new dims.
await relayoutAllEdges(d.id);
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.
function hitTestEndpointTarget(clientX, clientY) {
// elementsFromPoint walks the z-order so we can skip the dragged
// cable handle itself (it sits at the top while pointer-captured).
const els = document.elementsFromPoint(clientX, clientY);
for (const el of els) {
if (!(el instanceof Element)) continue;
if (el.classList?.contains("cable-handle")) continue; // skip self
const portID = el.getAttribute && el.getAttribute("data-port-id");
if (portID) return { kind: "port", id: Number(portID) };
const devEl = el.closest && el.closest("[data-device-id]");
if (devEl) return { kind: "device", id: Number(devEl.getAttribute("data-device-id")) };
const ioEl = el.closest && el.closest("[data-io-id]");
if (ioEl) return { kind: "io", id: Number(ioEl.getAttribute("data-io-id")) };
}
return null;
}
// Endpoint-drag gesture: pointerdown on a .cable-handle starts a replug.
// While held, renderCanvas anchors the affected end at the cursor.
// On pointerup, hit-test the cursor to find the drop target:
// - port → PATCH {from|to: {port_id}}
// - device → PATCH {from|to: {device_id}}
// - IO → PATCH {from|to: {io_id}}
// - empty → cancel (revert)
// When the cable was auto, a successful drop also sends promote=true so
// the server flips it to manual (m took control). Cancel leaves auto alone.
function startCableReplug(e, cableID, end) {
if (!state.active) return;
e.stopPropagation();
e.preventDefault();
const c = state.cables.find((x) => x.id === cableID);
if (!c) return;
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
try { svg.setPointerCapture(e.pointerId); } catch {}
$(".canvas-wrap").classList.add("replugging");
const startWorld = svgPoint(e);
cableReplug = { cableID, end, x: startWorld.x, y: startWorld.y };
renderCanvas();
const onMove = (ev) => {
const p = svgPoint(ev);
cableReplug = { cableID, end, 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 drop = hitTestEndpointTarget(ev.clientX, ev.clientY);
// Clear the preview first so renderCanvas falls back to resolved anchors.
cableReplug = null;
if (!drop) {
renderCanvas();
return; // cancel
}
// Build the patch for the affected endpoint.
const ep =
drop.kind === "port" ? { port_id: drop.id } :
drop.kind === "device" ? { device_id: drop.id } :
drop.kind === "io" ? { io_id: drop.id } : null;
if (!ep) { renderCanvas(); return; }
const body = {};
if (end === "from") body.from = ep; else body.to = ep;
if (c.auto) body.promote = true;
// If m dropped on the same endpoint we already had, treat as cancel.
const sameAsBefore =
(drop.kind === "port" && ((end === "from" ? c.from_port_id : c.to_port_id) === drop.id)) ||
(drop.kind === "device" && ((end === "from" ? c.from_device_id : c.to_device_id) === drop.id)) ||
(drop.kind === "io" && ((end === "from" ? c.from_io_id : c.to_io_id) === drop.id));
if (sameAsBefore) { renderCanvas(); return; }
try {
const updated = await patchCable(state.active.id, c.id, body);
Object.assign(c, updated);
render();
} catch (err) {
alert(`Replug failed: ${err.message}`);
renderCanvas();
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
/** Port-click flow:
* - A cable draw is in progress (cableDrawFromPortID set):
* same port → cancel; another port → finish the cable.
@@ -2866,6 +3230,7 @@ async function boot() {
$("#btn-solve").addEventListener("click", openSolveModal);
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
$("#btn-export").addEventListener("click", exportCurrentProject);
$("#btn-fit").addEventListener("click", fitToContent);
$("#project-select").addEventListener("change", (e) => {
const v = /** @type {HTMLSelectElement} */ (e.target).value;
@@ -2873,6 +3238,9 @@ async function boot() {
});
bindTools();
viewFromURL();
applyViewBox();
updateZoomUI();
try {
[state.projects, state.cableTypes] = await Promise.all([

View File

@@ -192,6 +192,18 @@ body {
.device-rect.selected { stroke-width: 3; }
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
/* Bottom-right resize affordance per device. Subtle grey by default,
stronger on hover so m can find it without it dominating the rect. */
.device-resize-handle {
fill: rgba(120, 120, 120, 0.35);
stroke: rgba(60, 60, 60, 0.45);
stroke-width: 1;
cursor: nwse-resize;
}
.device-resize-handle:hover {
fill: rgba(60, 60, 60, 0.65);
}
.device-label {
fill: var(--text);
font-size: 12px;
@@ -235,6 +247,27 @@ body {
filter: drop-shadow(0 0 4px var(--accent));
}
/* Zoom cluster — % + Fit button next to Admin. */
.zoom-cluster {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
#zoom-pct {
font-size: 12px;
color: var(--text-muted);
min-width: 38px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.canvas-wrap.panning #canvas,
.canvas-wrap.panning #canvas * { cursor: grabbing !important; }
.canvas-wrap.space-pan-ready #canvas,
.canvas-wrap.space-pan-ready #canvas * { cursor: grab !important; }
/* Header toast — slice 8 export feedback */
.toast {
display: inline-block;
@@ -374,6 +407,17 @@ body {
.cable-line:hover { stroke-width: 4; }
.cable-line.selected { stroke-width: 4; }
/* Endpoint handles — only rendered for the currently-selected cable.
Grab cursor on idle, grabbing while dragging (.replugging on root). */
.cable-handle {
cursor: grab;
stroke-width: 2;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35));
}
.cable-handle:hover { stroke-width: 3; }
.canvas-wrap.replugging .cable-handle,
.canvas-wrap.replugging #canvas * { cursor: grabbing !important; }
/* Solve preview-diff modal */
.modal-wide { width: 560px; }