Compare commits

..

16 Commits

Author SHA1 Message Date
mAi
61bc1dcf43 feat(ui): port editor + add-port form in the sidebar inspector
m: 'Add port' should be a sidebar form, not a two-step canvas gesture.

- Port inspector gains a Type dropdown (read /api/cable-types via
  state.cableTypes, PATCH /ports/:id with type_id). Edge picker + label
  + delete from prior shift are unchanged.
- New "Add port" form rendered from selection.kind === "port_new":
  Type / Edge / Label, Create + Cancel buttons. Default label is the
  next free index for the chosen type on this device ("HDMI 3" if two
  HDMIs already live there). Recomputes when m changes the type, but
  stops recomputing as soon as m hand-edits the label.
- +Port in the device inspector now flips selection to port_new,
  rendering the form. Submit → POST → switch to the new port's editor.
  No second canvas click required.
- Clicking a port row in the device inspector's port list selects that
  port and opens its editor (same surface as canvas-click).
- "← <device name>" back-link in both port editor and add-port form
  jumps back to the device inspector.

Removed: state.tool === "port" branch, armPortTool helper, placePortAt
function, .tool-port CSS, state.portToolDevice / portToolTypeID. The
canvas-armed +Port tool was the user-trip-wire perseus flagged; the
sidebar form replaces it entirely.

snapToDeviceEdge also removed — placePortAt was its only caller; the
edgeCentre + portEdge + relayoutEdge trio fully owns port placement
now.

Port rows in the device inspector get a hover background + pointer
cursor to read as clickable.
2026-05-16 11:40:45 +02:00
mAi
056777f1c1 merge: template-apply creates frame + grid-places devices inside
ApplyTemplate now creates a frame named after the template
('Living Room' etc, suffixed on collision), computes a uniform grid
(cols=min(ceil(sqrt(N)),4), rows=ceil(N/cols)), and places each
device inside the frame with frame_id set.

Frontend unchanged — activateProject re-hydrates the snapshot
including the new frame.

Tests cover frame creation + in-frame placement + name-collision
suffix. Verified on mDock: Living Room template → frame (200,200,
294×200) with TV/Soundbar at row 0 and ChromeCast wrapping to row 1.
2026-05-16 11:35:25 +02:00
mAi
2aff5eb04d feat(template): apply-template lands devices inside a named frame
Before: ApplyTemplate dropped devices in a horizontal row at fixed
canvas coords with frame_id NULL — devices appeared anywhere and m
had no way to express "these belong together".

Now: each apply creates a frame named after the template (suffixed
"…  2/3/…" on name collision) and lays the devices out in a uniform
grid inside it. Grid is roughly square (cols = ceil(sqrt(N)), capped
at 4) with 30/50 px gaps and 32/48 px padding. Each device gets the
new frame's id and grid-cell coords.

Schema unchanged. ApplyTemplateResult.frames_added carries the new
frame so the frontend can refresh the canvas without a full snapshot
reload.

Tests:
- TestApplyTemplate_CreatesFrameAndPlacesDevicesInside — frame is
  created with the template's name, every device has frame_id set,
  every device sits inside the frame rect, no two devices share a
  grid cell.
- TestApplyTemplate_FrameNameSuffixOnCollision — pre-existing
  "Living Room" frame in the project ⇒ template's frame named
  "Living Room 2".
- Existing tests unchanged.
2026-05-16 11:30:32 +02:00
mAi
5c11bf33cb merge: port UX bundle — selection feedback + even-spacing + onUp + device colour
3 commits (491db73, b28fc0c, 86264d1):
- +Port now sets state.selection on the new port → inspector switches
  to the port panel + halo shows
- Ports relayout to even spacing along the affected edge on every
  add/delete/edge-change (no more invisible stacking)
- startDrag.onUp captures the rect in closure instead of reading
  currentTarget after pointerup (no more 'classList of null' spam)
- Device colour: dropped CSS stroke/fill hard-codes, inline style now
  paints the rect — picker actually changes the visible colour

All verified end-to-end on the deployed image.
2026-05-16 11:25:32 +02:00
mAi
86264d1284 fix(ui): device colour now actually shows on the canvas
CSS .device-rect hard-coded stroke + fill, overriding the
stroke=${d.color} SVG attribute the JS wrote. Author CSS beats
presentation attributes, so changing the device colour via the
inspector picker was invisible.

Drop the stroke/fill overrides from .device-rect; set both inline
on the rect element instead — stroke = the chosen colour, fill =
a 12% tint via color-mix so the device reads coloured without
becoming garish. Inline style beats class CSS, so the picker works.

Frames + IO markers don't currently expose a colour picker, so no
analogous fix needed there.
2026-05-16 11:23:47 +02:00
mAi
b28fc0c565 fix(ui): even-spacing relayout on every port-set change
m's stronger invariant: ports must never overlap and must line up on
their edge. Replace the slide-collision dedup with full even-spacing
re-layout — for N ports on an edge, position i goes to axis · i/(N+1)
for i=1..N.

- New portEdge(port, dev) — snaps a port's current offsets to the
  nearest of the four edges (same heuristic as snapToDeviceEdge).
- New relayoutEdge(deviceID, edge) — re-spaces every port on the
  device-edge and PATCHes the ones whose offsets actually change.
  Sort key: x_offset for top/bottom, y_offset for left/right —
  preserves m's "I dropped it roughly here" order.

Applied on:
- placePortAt — re-layout the edge after the new port is created.
- inspector edge picker — capture oldEdge, PATCH the port to the
  centre of newEdge, then re-layout BOTH old and new edges.
- port delete — re-layout the edge the deleted port was on so the
  survivors collapse back to even spacing.

snapToDeviceEdge reverted to its pre-dedup shape (drop the existingPorts
arg and resolveCollision helper); the layout invariant is owned by
relayoutEdge now. edgeOf folded into portEdge.
2026-05-16 11:19:16 +02:00
mAi
491db730eb fix(ui): +Port feedback + snap dedup + startDrag closure-capture
Three changes from sherlock's Playwright debug (docs/sherlock-+port-bug.md):

1. Select the freshly-placed port. placePortAt now sets
   state.selection = {kind:"port", id:port.id} before render() so the
   inspector switches to the port panel and the .selected halo makes
   the new circle visible — fixes m's "+Port does nothing" perception
   (the port WAS being created server-side; it just rendered invisibly
   stacked under an existing one and the inspector stayed on the device).

2. Snap-to-edge dedup. snapToDeviceEdge now takes the existing ports
   on the device; if the computed (xOff, yOff) lands within 8px of a
   peer on the same edge, slide along the edge in 16px steps until a
   free slot is found. Eliminates pixel-perfect port stacks.

3. startDrag closure-capture. onUp asynchronously referenced
   e.currentTarget after pointerup nulled it, throwing a TypeError
   in the console on every click-only device selection. Capture
   dragTarget in the outer closure and use that inside add/remove.
2026-05-16 11:12:13 +02:00
mAi
90157dfd14 merge: migration 006 — IOx-* and Multi-plug-* are power strips
m: 'IOx-8 should have 8 powerports on the front, one on the back'.
Migration 006 reshapes all 8 power-distribution types (IOx-3/6/8,
Multi-plug 3/4/5/6, Wifi-plug) into 1 Power In on top (back) +
N Power Out on bottom (front).

Existing devices keep their old ports per design §2.3 — delete +
recreate to pick up the new layout.

Verified on mDock: IOx-8 ports = [(top, Power In, 1), (bottom,
Power Out, 8)].
2026-05-16 11:08:13 +02:00
mAi
f1af2820e1 fix(catalog): migration 006 — IOx-* and Multi-plug-* are power strips
m's actual hardware: IOx-3/6/8 are power strips, not USB hubs. v4 seeded
them as Power × 1 + USB × N which doesn't match reality. Multi-plug 3-6
and Wifi-plug from v5 lumped every Power port on the same bottom edge
without distinguishing input from outputs.

Migration 006 wipes and re-seeds the port profile for all 8
power-distribution types with the canonical 2-row layout:

  Power In  × 1 on top    (back, sort_order 0)
  Power Out × N on bottom (front, sort_order 1)

N for each:
  IOx-3 / Multi-plug 3 → 3
  IOx-6 / Multi-plug 6 → 6
  IOx-8                → 8
  Multi-plug 4         → 4
  Multi-plug 5         → 5
  Wifi-plug            → 1 (pass-through outlet)

Existing device instances keep their already-seeded ports per design
§2.3 (ports are instance-owned). m needs to delete + recreate any
IOx-* / Multi-plug-* / Wifi-plug instances to pick up the new layout.

Tests:
- TestSeed_PortProfiles: comments updated; totals unchanged (Power In 1
  + Power Out N matches old Power 1 + USB N / Power N).
- TestSeed_PowerHubs (was TestSeed_PowerCatalog, rewritten): table-drives
  all 8 affected types. Asserts exactly 2 port rows — top/Power In/1 and
  bottom/Power Out/N — plus kind/icon for the v5 catalog entries.

Design §2.2 catalog table refreshed to match.
2026-05-16 11:03:32 +02:00
mAi
3276cfeb17 merge: port UX — coloured fill + selectable + edge picker
picasso shipped (1 commit @ 82cf5a3, +157/-28):
- onPortPointerDown rewritten into 4 deterministic branches:
  cable-draw-in-progress | no-tool-no-draw | cable-tool | other-tools
  (bubble). Other-tools branch is what makes +Port placement work
  when the click lands on an existing port — the previous handler
  silently returned for any non-cable tool.
- Port circles fill + stroke in cable-type colour. .selected halo.
- New renderInspectorPort: type swatch + label + edge dropdown
  (Top/Right/Bottom/Left) + delete. Edge change PATCHes x_offset
  and y_offset to the chosen side's centre.

End-to-end verified on deployed image via PATCH /ports/:id round-trip.
2026-05-16 02:21:09 +02:00
mAi
82cf5a3052 fix(ui): port UX — coloured fill, selectable, edge picker
Three bundled fixes to slice 7's port flow:

1. Port-pointerdown branches deterministically:
   - cable-draw in progress → finish / cancel
   - no tool, no draw → select port (inspector opens)
   - cable tool → start a draw from this port
   - any other tool armed → bubble (so +Port can place a new port even
     when the click lands on top of an existing one)

2. Port circles now fill *and* stroke with the cable_type colour so the
   port reads as obviously coloured against the device rect. Selection
   adds a drop-shadow halo.

3. Port inspector — clicking a port (no other tool armed) selects it
   and shows a panel with cable-type swatch, label input, edge selector
   (Top / Right / Bottom / Left), and Delete. Changing the edge PATCHes
   x_offset / y_offset to the centre of the chosen side.

snapToDeviceEdge already picks the nearest of the four edges, so
placement on +Port lands correctly without further changes.
2026-05-16 02:15:11 +02:00
mAi
5d055ad521 merge: catalog-power — Multi-plug 3/4/5/6 + Wifi-plug
Migration 005 adds 5 power-distribution device types. Total
device_types now 21.
2026-05-16 02:07:17 +02:00
mAi
93b276875e feat(catalog): migration 005 — power-distribution devices
Adds 5 built-in device_types (project_id NULL, built_in=1):
- Multi-plug 3/4/5/6 (kind=hub, 🔌) — Power × N+1 (1 in + N out)
- Wifi-plug (kind=accessory, 📶) — Power × 2 pass-through outlet

The solver treats every Power port identically regardless of in/out
direction; m knows which end is which from the physical setup.

Tests:
- TestSeed_BuiltInDeviceTypes: built-in count rises from 16 → 21.
- TestSeed_PortProfiles: new entries' port totals.
- TestSeed_PowerCatalog (new, table-driven): asserts kind, icon, and
  the single Power port row for each of the 5 new types.
2026-05-16 02:05:30 +02:00
mAi
205e9eab26 merge: fix mxdrw auth — Bearer → HTTP Basic
mxdrw on mlake uses BASIC_AUTH_USER + BASIC_AUTH_PASS; slice 8's
Bearer design didn't match. Swapped req.SetBasicAuth(MEXDRAW_USER,
MEXDRAW_PASS). DEPLOY-VERIFY drawing on mxdrw confirms end-to-end
export from the deployed image works.
2026-05-16 02:01:16 +02:00
mAi
fe6f86593e fix(export): switch mxdrw auth from Bearer to HTTP Basic
mxdrw expects HTTP Basic Auth (BASIC_AUTH_USER + BASIC_AUTH_PASS on the
server side). Replace MEXDRAW_TOKEN with MEXDRAW_USER + MEXDRAW_PASS,
use req.SetBasicAuth on the export PUT.

Updated docker-compose.yml comment and README env table to match.
Roundtrip verified locally against mxdrw.msbls.de.
2026-05-16 01:49:23 +02:00
mAi
a7835468a1 merge: slice 8 — Excalidraw export to mxdrw.msbls.de
picasso shipped (2 commits): internal/exporter pure BuildScene +
Generate21 (crypto/rand base62 IDs), internal/db/excalidraw_ids.go
idempotent persistence, internal/server/export.go POST handler with
bearer auth + 10s timeout, frontend Export button + toast.

6 new exporter tests + 60+ existing all green with -race. Hand-test
roundtrip vs mxdrw confirmed: 20 elements per spec, IDs stable across
re-exports.

Deploy to mDock blocked on MEXDRAW_TOKEN — picasso correctly refused
to fake the secret. m to drop value into /home/m/secrets/mcables/.env
on mdock, then redeploy.
2026-05-16 01:42:17 +02:00
12 changed files with 828 additions and 127 deletions

View File

@@ -43,8 +43,9 @@ JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default.
|---|---|---|
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. |
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. |
| `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. |
### Tests

View File

@@ -14,7 +14,7 @@ services:
- MCABLES_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db
env_file:
# Empty for slice 1. MEXDRAW_TOKEN lands here when slice 5 ships.
# MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
- /home/m/secrets/mcables/.env
volumes:
- /home/m/stacks/mcables/data:/app/data

View File

@@ -453,18 +453,26 @@ Office setup template:
| fritz | network | Power × 1; RJ45 × 4 |
| ChromeCast | display | Power × 1; HDMI × 1 |
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
| IOx-3 | hub | Power × 1; (3× port slots — concrete cable type per slot is set at instantiation; defaults to USB × 3 for v0) |
| IOx-6 | hub | Power × 1; USB × 6 |
| IOx-8 | hub | Power × 1; USB × 8 |
| IOx-3 | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| IOx-6 | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| IOx-8 | hub | Power In × 1 (top/back); Power Out × 8 (bottom/front) |
| **Screen** | display | Power × 1; HDMI × 1 |
| **Keyboard** | accessory | USB × 1 |
| **Mouse** | accessory | USB × 1 |
| **Multi-plug 3** | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| **Multi-plug 4** | hub | Power In × 1 (top/back); Power Out × 4 (bottom/front) |
| **Multi-plug 5** | hub | Power In × 1 (top/back); Power Out × 5 (bottom/front) |
| **Multi-plug 6** | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| **Wifi-plug** | accessory | Power In × 1 (top/back); Power Out × 1 (bottom/front) — pass-through outlet |
"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing
shows them in red because most carry Power, but they also hub USB). v0
seeds them as USB hubs; m overrides per-instance. The catalog is editable
in the UI (slice 4.5 — "Manage device types") so m can refine the IOx-3
profile once and not re-override every instance.
v5 (migration 005) added the Multi-plug 36 strips and the Wifi-plug
pass-through outlet. v6 (migration 006) re-shaped the IOx-* and
Multi-plug-* profiles to the "1 in on top / N out on bottom" layout —
the IOx-* devices are physical power strips, not USB hubs (m's
hardware), and the Multi-plug-* outputs are now visually distinct from
the input. Convention: `top = back`, `bottom = front`. Existing device
instances keep their already-seeded ports per §2.3 — to pick up the
new layout, delete + re-create the instance.
m can also add **project-custom types** at any time (UI: "+ New device
type" inside the device-create modal) with `project_id = current`.

View File

@@ -17,6 +17,7 @@ func TestSeed_BuiltInDeviceTypes(t *testing.T) {
"NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz",
"ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8",
"Screen", "Keyboard", "Mouse",
"Multi-plug 3", "Multi-plug 4", "Multi-plug 5", "Multi-plug 6", "Wifi-plug",
}
if len(got) != len(want) {
t.Fatalf("built-in count = %d, want %d", len(got), len(want))
@@ -54,12 +55,17 @@ func TestSeed_PortProfiles(t *testing.T) {
"fritz": {5}, // Power 1 + RJ45 4
"ChromeCast": {2}, // Power 1 + HDMI 1
"SteamLink": {4}, // Power 1 + HDMI 1 + USB 2
"IOx-3": {4}, // Power 1 + USB 3
"IOx-6": {7}, // Power 1 + USB 6
"IOx-8": {9}, // Power 1 + USB 8
"Screen": {2}, // Power 1 + HDMI 1
"Keyboard": {1}, // USB 1
"Mouse": {1}, // USB 1
"IOx-3": {4}, // Power In 1 + Power Out 3 (after v6)
"IOx-6": {7}, // Power In 1 + Power Out 6 (after v6)
"IOx-8": {9}, // Power In 1 + Power Out 8 (after v6)
"Screen": {2}, // Power 1 + HDMI 1
"Keyboard": {1}, // USB 1
"Mouse": {1}, // USB 1
"Multi-plug 3": {4}, // Power In 1 + Power Out 3 (after v6)
"Multi-plug 4": {5}, // Power In 1 + Power Out 4 (after v6)
"Multi-plug 5": {6}, // Power In 1 + Power Out 5 (after v6)
"Multi-plug 6": {7}, // Power In 1 + Power Out 6 (after v6)
"Wifi-plug": {2}, // Power In 1 + Power Out 1 (after v6)
}
for name, want := range cases {
dt, ok := byName[name]
@@ -77,6 +83,80 @@ func TestSeed_PortProfiles(t *testing.T) {
}
}
// TestSeed_PowerHubs locks down the post-migration-006 port profile for
// every power-distribution device type: IOx-3/6/8, Multi-plug 3/4/5/6,
// and Wifi-plug. Each carries exactly two profile rows — a single
// "Power In" port on the top (back) edge and N "Power Out" ports on the
// bottom (front) edge, where N is the device-specific output count.
//
// This test covers the v5 catalog identity (kind, icon, built-in) for
// the 5 power-distribution types and the v6 port-profile fix for all
// 8 hubs in one table.
func TestSeed_PowerHubs(t *testing.T) {
s := newTestStore(t)
all, err := s.ListBuiltInDeviceTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != 21 {
t.Errorf("built-in count = %d, want 21 (16 from v4 + 5 from v5)", len(all))
}
byName := map[string]DeviceType{}
for _, d := range all {
byName[d.Name] = d
}
cases := []struct {
name string
// kind/icon are only set for the 5 v5-power types; empty means
// "don't check" (the IOx-* keep their v4-seeded kind=hub icon=nil).
kind string
icon string
outCount int // N — number of "Power Out" outlets on the bottom edge
}{
// v5 catalog (kind+icon checked)
{name: "Multi-plug 3", kind: "hub", icon: "🔌", outCount: 3},
{name: "Multi-plug 4", kind: "hub", icon: "🔌", outCount: 4},
{name: "Multi-plug 5", kind: "hub", icon: "🔌", outCount: 5},
{name: "Multi-plug 6", kind: "hub", icon: "🔌", outCount: 6},
{name: "Wifi-plug", kind: "accessory", icon: "📶", outCount: 1},
// v4 hubs re-shaped by v6 (kind/icon left blank → not checked)
{name: "IOx-3", outCount: 3},
{name: "IOx-6", outCount: 6},
{name: "IOx-8", outCount: 8},
}
for _, c := range cases {
dt, ok := byName[c.name]
if !ok {
t.Errorf("missing %q", c.name)
continue
}
if !dt.BuiltIn {
t.Errorf("%s: built_in should be true", c.name)
}
if dt.ProjectID != nil {
t.Errorf("%s: project_id should be nil", c.name)
}
if c.kind != "" && dt.Kind != c.kind {
t.Errorf("%s: kind = %q, want %q", c.name, dt.Kind, c.kind)
}
if c.icon != "" && (dt.Icon == nil || *dt.Icon != c.icon) {
t.Errorf("%s: icon = %v, want %q", c.name, dt.Icon, c.icon)
}
if len(dt.Ports) != 2 {
t.Errorf("%s: expected 2 port-profile rows, got %d", c.name, len(dt.Ports))
continue
}
in := dt.Ports[0]
out := dt.Ports[1]
if in.CableTypeID != 1 || in.Count != 1 || in.Edge != "top" || in.LabelPrefix != "Power In" {
t.Errorf("%s: Power In row mismatch: %+v", c.name, in)
}
if out.CableTypeID != 1 || out.Count != c.outCount || out.Edge != "bottom" || out.LabelPrefix != "Power Out" {
t.Errorf("%s: Power Out row mismatch: %+v (want count=%d)", c.name, out, c.outCount)
}
}
}
// -------------------------------------------------------- CRUD (custom rows)
func TestCreateDeviceType_CustomBasic(t *testing.T) {

View File

@@ -0,0 +1,32 @@
-- mCables 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
-- solver treats every Power port identically regardless of in/out
-- direction; m knows which end is which from the physical setup.
--
-- Wifi-plug is a pass-through outlet (Power × 2: one in, one out).
INSERT INTO device_types (name, kind, icon, built_in, description) VALUES
('Multi-plug 3', 'hub', '🔌', 1, '3-way power strip (1 in + 3 out)'),
('Multi-plug 4', 'hub', '🔌', 1, '4-way power strip (1 in + 4 out)'),
('Multi-plug 5', 'hub', '🔌', 1, '5-way power strip (1 in + 5 out)'),
('Multi-plug 6', 'hub', '🔌', 1, '6-way power strip (1 in + 6 out)'),
('Wifi-plug', 'accessory', '📶', 1, 'WiFi-controllable pass-through outlet');
-- Port profiles. cable_types id 1 = Power (seeded in 001).
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 4, 'bottom', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 5, 'bottom', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 6, 'bottom', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 7, 'bottom', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 2, 'bottom', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;

View File

@@ -0,0 +1,87 @@
-- mCables 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
-- the back, N power outputs on the front). v5's Multi-plug 3/4/5/6
-- profiles also lumped every Power port on the bottom edge without
-- distinguishing the input from the outputs.
--
-- This migration replaces the port profile for the 8 power-distribution
-- types with the canonical "1 in (top/back) + N out (bottom/front)"
-- layout. Convention: top=back, bottom=front.
--
-- N for each type:
-- IOx-3 / Multi-plug 3 → 3 outputs
-- IOx-6 → 6 outputs
-- IOx-8 → 8 outputs
-- Multi-plug 4 → 4 outputs
-- Multi-plug 5 → 5 outputs
-- Multi-plug 6 → 6 outputs
-- Wifi-plug → 1 output (it's a pass-through outlet)
--
-- Existing devices m may have created with the old profile keep their
-- already-seeded ports — per design §2.3, ports are instance-owned. To
-- get the new layout on an existing instance, delete it and re-create.
--
-- cable_types id 1 = Power (seeded in 001).
-- 1) Drop the existing port-profile rows for each affected type.
DELETE FROM device_type_ports
WHERE device_type_id IN (
SELECT id FROM device_types
WHERE project_id IS NULL
AND name IN (
'IOx-3', 'IOx-6', 'IOx-8',
'Multi-plug 3', 'Multi-plug 4', 'Multi-plug 5', 'Multi-plug 6',
'Wifi-plug'
)
);
-- 2) Insert the canonical (1 in on top, N out on bottom) profile.
-- IOx-3 — 1 in + 3 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
-- IOx-6 — 1 in + 6 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
-- IOx-8 — 1 in + 8 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 8, 'bottom', 1 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
-- Multi-plug 3 — 1 in + 3 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
-- Multi-plug 4 — 1 in + 4 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 4, 'bottom', 1 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
-- Multi-plug 5 — 1 in + 5 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 5, 'bottom', 1 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
-- Multi-plug 6 — 1 in + 6 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
-- Wifi-plug — 1 in + 1 out (pass-through outlet)
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 1, 'bottom', 1 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;

View File

@@ -191,10 +191,11 @@ type UnsatisfiedReq struct {
// ApplyTemplateResult is the response from POST /apply-template.
type ApplyTemplateResult struct {
DevicesAdded []Device `json:"devices_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
FramesAdded []Frame `json:"frames_added"`
DevicesAdded []Device `json:"devices_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
}
type SkippedTemplateDevice struct {

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"strings"
)
@@ -161,6 +162,7 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
}
out := &ApplyTemplateResult{
FramesAdded: []Frame{},
DevicesAdded: []Device{},
RequirementsAdded: []ConnectionRequirement{},
SkippedDevices: []SkippedTemplateDevice{},
@@ -171,8 +173,8 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
opts.OriginX, opts.OriginY = 200, 200
}
// Pull existing device names in the project so we can pre-check
// collisions without aborting the whole transaction.
// Pull existing device + frame names in the project so we can
// pre-check collisions without aborting the whole transaction.
existing, err := s.ListDevices(projectID, nil)
if err != nil {
return nil, err
@@ -181,6 +183,14 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
for _, d := range existing {
nameTaken[d.Name] = true
}
existingFrames, err := s.ListFrames(projectID)
if err != nil {
return nil, err
}
frameNameTaken := map[string]bool{}
for _, f := range existingFrames {
frameNameTaken[f.Name] = true
}
tx, err := s.db.Begin()
if err != nil {
@@ -188,6 +198,37 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
}
defer tx.Rollback()
// Plan a uniform grid for the template's devices inside a new frame
// named after the template. The grid drives both frame size and
// per-device (x, y). Devices that get skipped (name collision /
// SkipDevices) leave their grid cell empty.
const (
devW, devH = 100.0, 35.0
gapX, gapY = 30.0, 50.0
padX, padY = 32.0, 48.0 // padY larger so the frame title clears row 1
)
n := len(tmpl.Devices)
cols := 1
if n > 0 {
cols = min(int(math.Ceil(math.Sqrt(float64(n)))), 4)
}
rows := 1
if n > 0 {
rows = (n + cols - 1) / cols
}
frameW := padX*2 + float64(cols)*devW + float64(cols-1)*gapX
frameH := padY + padX + float64(rows)*devH + float64(rows-1)*gapY
frameName := pickFrameName(tmpl.Name, frameNameTaken)
frame, err := createFrameTx(tx, projectID, FrameCreate{
Name: frameName, X: opts.OriginX, Y: opts.OriginY,
Width: frameW, Height: frameH,
})
if err != nil {
return nil, fmt.Errorf("seed frame %q: %w", frameName, err)
}
out.FramesAdded = append(out.FramesAdded, *frame)
// Map: template_device_id → newly-created device_id (or 0 if skipped).
tmplToDevice := map[int64]int64{}
@@ -215,17 +256,22 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
tmplToDevice[td.ID] = 0
continue
}
// Lay out devices in a horizontal row near the origin, 150 px apart.
x := opts.OriginX + float64(i)*150
y := opts.OriginY
// Use createDeviceTx so the port-seeding share the same transaction.
// Grid cell (col, row) within the frame. Cell anchor is the
// top-left of the device rect; offsets are added to the frame's
// own (x, y) so the device sits inside the frame.
col := i % cols
row := i / cols
x := frame.X + padX + float64(col)*(devW+gapX)
y := frame.Y + padY + float64(row)*(devH+gapY)
// Use createDeviceTx so port-seeding shares the same transaction.
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
Name: name,
TypeID: &td.DeviceTypeID,
FrameID: &frame.ID,
X: x,
Y: y,
Width: 100,
Height: 35,
Width: devW,
Height: devH,
})
if err != nil {
return nil, fmt.Errorf("seed %s: %w", name, err)
@@ -294,6 +340,58 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
return out, nil
}
// pickFrameName returns a frame name that doesn't collide with anything
// in `taken`. Tries the template name first, then "<name> 2", "<name> 3",
// and so on.
func pickFrameName(base string, taken map[string]bool) string {
if !taken[base] {
return base
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s %d", base, i)
if !taken[candidate] {
return candidate
}
}
}
// createFrameTx inserts a frame inside the caller's transaction. Mirrors
// the validation in CreateFrame (name + positive size) but avoids the
// s.db.Exec call so ApplyTemplate can keep everything on the same
// connection under MaxOpenConns(1).
func createFrameTx(tx *sql.Tx, projectID int64, f FrameCreate) (*Frame, error) {
name := strings.TrimSpace(f.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if f.Width <= 0 || f.Height <= 0 {
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
}
res, err := tx.Exec(
`INSERT INTO frames (project_id, name, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, name, f.X, f.Y, f.Width, f.Height,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
var out Frame
var ex sql.NullString
err = tx.QueryRow(
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&out.ID, &out.ProjectID, &out.Name, &out.X, &out.Y, &out.Width, &out.Height,
&ex, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
return nil, err
}
if ex.Valid {
out.ExcalidrawID = &ex.String
}
return &out, nil
}
// createDeviceTx is a tx-aware variant of CreateDevice used by
// ApplyTemplate so seeding the template's devices + their ports stays
// inside one atomic apply.

View File

@@ -234,6 +234,76 @@ func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
}
}
func TestApplyTemplate_CreatesFrameAndPlacesDevicesInside(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
tmpls, _ := s.ListSetupTemplates()
var lr SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Living Room" {
lr = tm
break
}
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.FramesAdded) != 1 {
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
}
frame := res.FramesAdded[0]
if frame.Name != "Living Room" {
t.Errorf("frame name = %q, want %q", frame.Name, "Living Room")
}
for _, d := range res.DevicesAdded {
if d.FrameID == nil || *d.FrameID != frame.ID {
t.Errorf("device %q: frame_id = %v, want %d", d.Name, d.FrameID, frame.ID)
}
// Device top-left should be inside the frame rect.
if d.X < frame.X || d.X+d.Width > frame.X+frame.Width {
t.Errorf("device %q: x=%v width=%v outside frame [%v..%v]", d.Name, d.X, d.Width, frame.X, frame.X+frame.Width)
}
if d.Y < frame.Y || d.Y+d.Height > frame.Y+frame.Height {
t.Errorf("device %q: y=%v height=%v outside frame [%v..%v]", d.Name, d.Y, d.Height, frame.Y, frame.Y+frame.Height)
}
}
// No two devices share the same (X, Y) — the grid layout spreads them out.
seen := map[[2]float64]string{}
for _, d := range res.DevicesAdded {
key := [2]float64{d.X, d.Y}
if prev, ok := seen[key]; ok {
t.Errorf("devices %q and %q share grid cell (%v, %v)", prev, d.Name, d.X, d.Y)
}
seen[key] = d.Name
}
}
func TestApplyTemplate_FrameNameSuffixOnCollision(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Pre-create a frame called "Living Room" so the template's frame name collides.
_, _ = s.CreateFrame(p.ID, FrameCreate{Name: "Living Room", X: 0, Y: 0, Width: 100, Height: 100})
tmpls, _ := s.ListSetupTemplates()
var lr SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Living Room" {
lr = tm
break
}
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.FramesAdded) != 1 {
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
}
if res.FramesAdded[0].Name != "Living Room 2" {
t.Errorf("frame name = %q, want %q (suffixed)", res.FramesAdded[0].Name, "Living Room 2")
}
}
func TestApplyTemplate_NameCollisionSkipped(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")

View File

@@ -29,11 +29,12 @@ func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
if base == "" {
base = "https://mxdrw.msbls.de"
}
token := os.Getenv("MEXDRAW_TOKEN")
if token == "" {
user := os.Getenv("MEXDRAW_USER")
pass := os.Getenv("MEXDRAW_PASS")
if user == "" || pass == "" {
writeJSON(w, http.StatusBadRequest, errorBody{
Error: "MEXDRAW_TOKEN not set",
Details: "Add MEXDRAW_TOKEN to /home/m/secrets/mcables/.env on mDock and restart the container",
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",
})
return
}
@@ -77,7 +78,7 @@ func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.SetBasicAuth(user, pass)
resp, err := http.DefaultClient.Do(req)
if err != nil {

View File

@@ -56,14 +56,11 @@ const state = {
/** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
/** "frame" | "device" | "io" | "req" | "cable" | null */
tool: /** @type {string|null} */ (null),
/** Slice-7 transient state for the +Port tool. */
portToolDevice: /** @type {number|null} */ (null),
portToolTypeID: /** @type {number|null} */ (null),
/** 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", id: number} | null} */ selection: null,
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -293,10 +290,14 @@ function renderCanvas() {
for (const d of state.devices) {
const g = svgEl("g", { "data-device-id": d.id });
// Stroke = the user-picked colour; fill = a 12% tint of it via
// color-mix so the device "reads" coloured without becoming garish.
// Inline style beats the .device-rect class CSS, which is why CSS
// no longer hard-codes stroke/fill on that class.
const rect = svgEl("rect", {
x: d.x, y: d.y, width: d.width, height: d.height,
class: "device-rect svg-draggable",
stroke: d.color,
style: `stroke: ${d.color}; fill: color-mix(in srgb, ${d.color} 12%, white);`,
rx: 3, ry: 3,
});
if (state.selection?.kind === "device" && state.selection.id === d.id) {
@@ -310,21 +311,26 @@ function renderCanvas() {
g.append(rect, label);
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
// Stroke colour = the cable_type colour the port carries; fill stays white
// so the port reads against any device colour.
// Both fill and stroke = cable_type colour so the port is obviously coloured
// against the device rect.
const ports = portsByDevice.get(d.id) || [];
for (const prt of ports) {
const cx = d.x + prt.x_offset;
const cy = d.y + prt.y_offset;
const color = cableTypeColor.get(prt.type_id) || "#888";
const cls = "port-circle" + (state.cableDrawFromPortID === prt.id ? " cable-from" : "");
const isCableFrom = state.cableDrawFromPortID === prt.id;
const isSelected = state.selection?.kind === "port" && state.selection.id === prt.id;
const cls = "port-circle"
+ (isCableFrom ? " cable-from" : "")
+ (isSelected ? " selected" : "");
const c = svgEl("circle", {
cx, cy, r: 5,
class: cls,
fill: color,
stroke: color,
"data-port-id": prt.id,
});
// Slice 7: port-click drives the manual cable-draw flow.
// Port-click drives both cable-draw (slice 7) and port-select (this fix).
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
g.append(c);
}
@@ -431,6 +437,8 @@ function renderInspector() {
case "cable_type": return renderInspectorCableType(body, state.selection.id);
case "requirement": return renderInspectorRequirement(body, state.selection.id);
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);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
@@ -717,13 +725,24 @@ function renderInspectorDevice(body, id) {
});
});
// +Port — arms the port-placement gesture. Active cable type comes
// from the legend selection; if none, defaults to the first cable_type.
// +Port — switch the inspector to the new-port form. m fills in
// type + edge + label and clicks Create; no canvas click required.
body.querySelector("#dev-add-port").addEventListener("click", () => {
if (!state.active) return;
const typeID = state.activeTypeId ?? state.cableTypes[0]?.id;
if (!typeID) { alert("Pick a cable type in the legend first"); return; }
armPortTool(d.id, typeID);
state.selection = { kind: "port_new", device_id: d.id };
render();
});
// Clicking a port row in the device's port list selects that port
// and opens its editor in the inspector pane.
body.querySelectorAll(".port-row[data-port-id]").forEach((row) => {
row.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.closest(".port-del")) return;
const pid = Number(row.getAttribute("data-port-id"));
if (!pid) return;
state.selection = { kind: "port", id: pid };
render();
});
});
// Per-port delete.
@@ -993,6 +1012,244 @@ function renderInspectorIO(body, id) {
});
}
// 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) {
const prt = state.ports.find((p) => p.id === id);
if (!prt) { body.innerHTML = ""; return; }
const dev = state.devices.find((d) => d.id === prt.device_id);
if (!dev) { body.innerHTML = ""; return; }
const currentEdge = portEdge(prt, dev);
const typeOptions = state.cableTypes
.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
.join("");
body.innerHTML = `
<p class="section-title">Port</p>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-back-device" class="btn-link">← ${escapeHtml(dev.name)}</a>
</p>
<label class="field">
<span>Type</span>
<select id="port-type">${typeOptions}</select>
</label>
<label class="field">
<span>Edge</span>
<select id="port-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="port-delete">Delete</button>
</div>
`;
body.querySelector("#port-type").value = String(prt.type_id);
body.querySelector("#port-edge").value = currentEdge;
body.querySelector("#port-label").value = prt.label ?? "";
body.querySelector("#port-back-device").addEventListener("click", (e) => {
e.preventDefault();
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-type").addEventListener("change", async (e) => {
if (!state.active) return;
const newTypeID = Number(/** @type {HTMLSelectElement} */ (e.target).value);
if (newTypeID === prt.type_id) return;
try {
const updated = await patchPort(state.active.id, prt.id, { type_id: newTypeID });
Object.assign(prt, updated);
renderCanvas();
} catch (ex) {
alert(`Type change failed: ${ex.message}`);
}
});
bindDebouncedRename(body.querySelector("#port-label"), async (label) => {
if (!state.active) return;
const updated = await patchPort(state.active.id, prt.id, { label });
Object.assign(prt, updated);
renderCanvas();
});
body.querySelector("#port-edge").addEventListener("change", async (e) => {
if (!state.active) return;
const newEdge = /** @type {HTMLSelectElement} */ (e.target).value;
const oldEdge = portEdge(prt, dev);
if (newEdge === oldEdge) return;
// PATCH to a temp position on the new edge so portEdge() classifies
// this port onto newEdge in the upcoming relayouts. The temp position
// gets overwritten by relayoutEdge(newEdge); the only thing that
// matters is that the port is unambiguously on the right edge.
const tmp = edgeCentre(dev, newEdge);
try {
const updated = await patchPort(state.active.id, prt.id, {
x_offset: tmp.xOff, y_offset: tmp.yOff,
});
Object.assign(prt, updated);
// Re-space both affected edges: the one the port left and the one
// it landed on. Order doesn't matter — they operate on disjoint
// port sets.
await Promise.all([
relayoutEdge(dev.id, oldEdge),
relayoutEdge(dev.id, newEdge),
]);
renderCanvas();
} catch (ex) {
alert(`Move port failed: ${ex.message}`);
}
});
body.querySelector("#port-delete").addEventListener("click", async () => {
if (!state.active) return;
if (!confirm("Delete this port?")) return;
const wasEdge = portEdge(prt, dev);
try {
await deletePort(state.active.id, prt.id);
state.ports = state.ports.filter((p) => p.id !== prt.id);
const snap = await getSnapshot(state.active.id);
state.cables = snap.cables || [];
// Re-space the edge the deleted port was on so the survivors
// shift back to even spacing.
await relayoutEdge(dev.id, wasEdge);
state.selection = null;
render();
} catch (ex) {
alert(`Delete failed: ${ex.message}`);
}
});
}
// Centre of the named edge, expressed as (x_offset, y_offset) relative
// to the device origin. Used as a temp anchor when moving a port between
// edges — the precise centre value is immediately overwritten by
// relayoutEdge, but it has to land on the right edge.
function edgeCentre(dev, edge) {
switch (edge) {
case "top": return { xOff: dev.width / 2, yOff: 0 };
case "right": return { xOff: dev.width, yOff: dev.height / 2 };
case "bottom": return { xOff: dev.width / 2, yOff: dev.height };
case "left": return { xOff: 0, yOff: dev.height / 2 };
default: return { xOff: dev.width / 2, yOff: dev.height };
}
}
// Compute the next available default label for a new port of `typeID`
// on `deviceID`. e.g. if a TV already has "HDMI 1" and "HDMI 2", a new
// HDMI port gets "HDMI 3".
function nextDefaultPortLabel(deviceID, typeID) {
const ct = state.cableTypes.find((t) => t.id === typeID);
const prefix = ct?.name || "Port";
const sibs = state.ports.filter((p) => p.device_id === deviceID && p.type_id === typeID);
let max = 0;
for (const p of sibs) {
const m = (p.label || "").match(new RegExp("^" + escapeRegExp(prefix) + "\\s+(\\d+)$"));
if (m) {
const n = parseInt(m[1], 10);
if (n > max) max = n;
}
}
return `${prefix} ${Math.max(max + 1, sibs.length + 1)}`;
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// "Add port" form. Submit → POST → switch inspector to the new port's
// editor. m can cancel back to the device inspector.
function renderInspectorPortNew(body, deviceID) {
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) { body.innerHTML = ""; return; }
if (state.cableTypes.length === 0) {
body.innerHTML = `
<p class="section-title">Add port</p>
<p class="muted">No cable types defined. Add one from the legend first.</p>
<div class="inspector-actions">
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>`;
body.querySelector("#port-new-cancel").addEventListener("click", () => {
state.selection = { kind: "device", id: dev.id };
render();
});
return;
}
const defaultTypeID = state.activeTypeId ?? state.cableTypes[0].id;
const typeOptions = state.cableTypes
.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
.join("");
body.innerHTML = `
<p class="section-title">Add port</p>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-new-back" class="btn-link">← ${escapeHtml(dev.name)}</a>
</p>
<label class="field">
<span>Type</span>
<select id="port-new-type">${typeOptions}</select>
</label>
<label class="field">
<span>Edge</span>
<select id="port-new-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom" selected>Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-new-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-primary btn-tiny" id="port-new-create">Create</button>
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>
`;
const typeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-type"));
const edgeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-edge"));
const labelInp = /** @type {HTMLInputElement} */ (body.querySelector("#port-new-label"));
typeSel.value = String(defaultTypeID);
labelInp.value = nextDefaultPortLabel(dev.id, defaultTypeID);
labelInp.placeholder = labelInp.value;
// Recompute default label whenever the type changes (only if m hasn't
// edited the field).
let labelUserEdited = false;
labelInp.addEventListener("input", () => { labelUserEdited = true; });
typeSel.addEventListener("change", () => {
if (labelUserEdited) return;
const tid = Number(typeSel.value);
const next = nextDefaultPortLabel(dev.id, tid);
labelInp.value = next;
labelInp.placeholder = next;
});
body.querySelector("#port-new-back").addEventListener("click", (e) => {
e.preventDefault();
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-new-cancel").addEventListener("click", () => {
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-new-create").addEventListener("click", async () => {
const tid = Number(typeSel.value);
const edge = edgeSel.value;
const label = labelInp.value.trim();
await createPortFromForm(dev.id, tid, edge, label);
});
}
function renderInspectorCableType(body, id) {
const t = state.cableTypes.find((x) => x.id === id);
if (!t) { body.innerHTML = ""; return; }
@@ -1196,27 +1453,15 @@ function armTool(tool) {
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
wrap.classList.toggle("tool-port", tool === "port");
wrap.classList.toggle("tool-cable", tool === "cable");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
if (tool !== "port") {
state.portToolDevice = null;
state.portToolTypeID = null;
}
if (tool !== "cable") {
state.cableDrawFromPortID = null;
}
}
/** Slice 7: device inspector arms +Port for a specific device + type. */
function armPortTool(deviceID, typeID) {
state.portToolDevice = deviceID;
state.portToolTypeID = typeID;
armTool("port");
}
function bindTools() {
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
@@ -1268,11 +1513,6 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
if (state.tool === "port") {
e.preventDefault();
placePortAt(p);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
@@ -1451,49 +1691,117 @@ function openNewDeviceModal(geom) {
};
}
/** Snap (x, y) onto the closest edge of `device`. Returns the (x_off,
* y_off) relative to the device's top-left + a debug-friendly edge name. */
function snapToDeviceEdge(device, x, y) {
// Distance from the point to each of the four edges.
const dxLeft = Math.abs(x - device.x);
const dxRight = Math.abs((device.x + device.width) - x);
const dyTop = Math.abs(y - device.y);
const dyBottom = Math.abs((device.y + device.height) - y);
const min = Math.min(dxLeft, dxRight, dyTop, dyBottom);
// Clamp the perpendicular coordinate so the port sits *on* the rect.
const localX = Math.max(0, Math.min(device.width, x - device.x));
const localY = Math.max(0, Math.min(device.height, y - device.y));
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
return { xOff: localX, yOff: device.height, edge: "bottom" };
// Which edge does a given port currently sit on? Snaps the port's
// existing (x_offset, y_offset) to the nearest of the four edges.
function portEdge(port, device) {
const dL = port.x_offset;
const dR = device.width - port.x_offset;
const dT = port.y_offset;
const dB = device.height - port.y_offset;
const min = Math.min(dL, dR, dT, dB);
if (min === dL) return "left";
if (min === dR) return "right";
if (min === dT) return "top";
return "bottom";
}
// Even-spacing layout invariant for ports on a device edge: m wants
// every port lined up on its edge with no overlap. After any change
// to the set of ports on an edge (add / move / delete), recompute the
// offsets so that for N ports they sit at relative positions
// i/(N+1) along the edge for i=1..N.
//
// Sort key preserves m's intent: top/bottom by current x_offset
// (left→right), left/right by current y_offset (top→bottom). For a
// freshly-placed port, that's the click position projected onto the
// edge, so the port keeps its "I dropped it roughly here" rank.
//
// PATCHes only the ports whose offsets actually change, and updates
// state.ports in place. Returns once every PATCH resolves.
async function relayoutEdge(deviceID, edge) {
if (!state.active) return;
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) return;
const isHorizontal = edge === "top" || edge === "bottom";
const axis = isHorizontal ? dev.width : dev.height;
const peers = state.ports
.filter((p) => p.device_id === deviceID && portEdge(p, dev) === edge)
.slice()
.sort((a, b) =>
isHorizontal ? a.x_offset - b.x_offset : a.y_offset - b.y_offset);
const n = peers.length;
if (n === 0) return;
const patches = [];
for (let i = 0; i < n; i++) {
const parallel = axis * (i + 1) / (n + 1);
let xOff, yOff;
switch (edge) {
case "top": xOff = parallel; yOff = 0; break;
case "bottom": xOff = parallel; yOff = dev.height; break;
case "left": xOff = 0; yOff = parallel; break;
case "right": xOff = dev.width; yOff = parallel; break;
}
const p = peers[i];
if (p.x_offset === xOff && p.y_offset === yOff) continue;
p.x_offset = xOff;
p.y_offset = yOff;
patches.push(patchPort(state.active.id, p.id, { x_offset: xOff, y_offset: yOff })
.then((updated) => Object.assign(p, updated)));
}
if (patches.length) {
try {
await Promise.all(patches);
} catch (err) {
alert(`Re-layout failed: ${err.message}`);
}
}
}
/** Port-click flow:
* 1) No source picked yet → this port becomes the source. Highlight it.
* 2) Source already picked → this port is the target. POST a cable
* with `from_port_id` / `to_port_id`, type from the source port,
* auto=0. Shift-click flips the target to "bind to whole device"
* (uses `to_device_id` instead). */
* - A cable draw is in progress (cableDrawFromPortID set):
* same port → cancel; another port → finish the cable.
* - Otherwise, no tool armed:
* select the port (inspector shows edge picker + label + delete).
* - Otherwise, any non-cable tool armed:
* bubble so the canvas-level tool handler runs (lets +Port place
* a new port even when the click lands on an existing one). */
function onPortPointerDown(e, port) {
if (!state.active) return;
if (state.tool && state.tool !== "cable") return; // other tool wins
e.stopPropagation();
e.preventDefault();
if (state.cableDrawFromPortID == null) {
// Cable-draw flow takes precedence whenever a source is already picked.
if (state.cableDrawFromPortID != null) {
e.stopPropagation();
e.preventDefault();
if (state.cableDrawFromPortID === port.id) {
state.cableDrawFromPortID = null;
armTool(null);
render();
return;
}
finishCableDrawAt(port, e.shiftKey);
return;
}
// No cable in progress, no tool: select the port → inspector pane.
if (!state.tool) {
e.stopPropagation();
e.preventDefault();
state.selection = { kind: "port", id: port.id };
render();
return;
}
// The cable tool: start a draw from this port.
if (state.tool === "cable") {
e.stopPropagation();
e.preventDefault();
state.cableDrawFromPortID = port.id;
armTool("cable"); // get the crosshair cursor + visual cue
render();
return;
}
if (state.cableDrawFromPortID === port.id) {
// Cancel — clicked the same port again.
state.cableDrawFromPortID = null;
armTool(null);
render();
return;
}
finishCableDrawAt(port, e.shiftKey);
// Any other tool (port / frame / device / io / req): let the click
// bubble up so the canvas-level branch fires.
}
async function finishCableDrawAt(targetPort, shiftKey) {
@@ -1558,26 +1866,28 @@ async function finishCableDrawAtIO(ioMarker) {
}
}
async function placePortAt(p) {
// Create a port from the sidebar "Add port" form and switch the
// inspector to its editor. Used by renderInspectorPortNew on submit.
async function createPortFromForm(deviceID, typeID, edge, label) {
if (!state.active) return;
const did = state.portToolDevice;
const tid = state.portToolTypeID;
if (did == null || tid == null) { armTool(null); return; }
const dev = state.devices.find((d) => d.id === did);
if (!dev) { armTool(null); return; }
const snap = snapToDeviceEdge(dev, p.x, p.y);
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) return;
const tmp = edgeCentre(dev, edge);
try {
const port = await createPort(state.active.id, did, {
type_id: tid,
x_offset: snap.xOff,
y_offset: snap.yOff,
const port = await createPort(state.active.id, deviceID, {
type_id: typeID,
label: label || undefined,
x_offset: tmp.xOff,
y_offset: tmp.yOff,
});
state.ports.push(port);
armTool(null);
// Re-space every port on this edge so the new one slots into the
// even-spacing grid.
await relayoutEdge(deviceID, edge);
state.selection = { kind: "port", id: port.id };
render();
} catch (e) {
alert(`Add port failed: ${e.message}`);
armTool(null);
}
}
@@ -1697,7 +2007,13 @@ function startDrag(e, kind, id) {
}
}
e.currentTarget.classList.add("dragging");
// Capture the rect element NOW: by the time onUp fires async, the
// browser has nulled out e.currentTarget on the pointerdown event,
// so `e.currentTarget.classList.remove("dragging")` would throw
// "Cannot read properties of null". Sherlock surfaced this from the
// click-only path that pageerror-spammed every device click.
const dragTarget = /** @type {Element} */ (e.currentTarget);
dragTarget.classList.add("dragging");
svg.setPointerCapture(e.pointerId);
let dragged = false;
@@ -1719,7 +2035,7 @@ function startDrag(e, kind, id) {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
e.currentTarget.classList.remove("dragging");
dragTarget.classList.remove("dragging");
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
if (!state.active) return;

View File

@@ -183,9 +183,10 @@ body {
pointer-events: none;
}
/* Stroke + fill come from the device's user-set colour, written as
inline style in renderCanvas — leaving them out of .device-rect so
the author CSS doesn't override the inline style. */
.device-rect {
fill: #fff;
stroke: var(--text);
stroke-width: 1.5;
}
.device-rect.selected { stroke-width: 3; }
@@ -214,8 +215,6 @@ body {
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-port #canvas,
.canvas-wrap.tool-port #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
@@ -277,16 +276,17 @@ body {
user-select: none;
}
/* Ports — small circles laid out along the device edge. The fill is
white so the port is visible regardless of the underlying device's
stroke; the stroke colour comes from the cable_type the port carries
(set inline in JS). */
/* Ports — small circles laid out along the device edge. Both fill and
stroke come from the cable_type the port carries (set inline in JS)
so the port reads clearly as a coloured anchor on the device. */
.port-circle {
fill: #fff;
stroke: var(--text);
stroke-width: 2;
cursor: crosshair;
}
.port-circle.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.port-row {
display: grid;
@@ -294,13 +294,20 @@ body {
align-items: center;
gap: 6px;
font-size: 12px;
padding: 2px 0;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
}
.port-row .swatch {
.port-row:hover { background: var(--surface-2); }
.port-row .swatch,
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
margin-right: 6px;
vertical-align: middle;
}
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }