Compare commits
16 Commits
mai/picass
...
mai/picass
| Author | SHA1 | Date | |
|---|---|---|---|
| 61bc1dcf43 | |||
| 056777f1c1 | |||
| 2aff5eb04d | |||
| 5c11bf33cb | |||
| 86264d1284 | |||
| b28fc0c565 | |||
| 491db730eb | |||
| 90157dfd14 | |||
| f1af2820e1 | |||
| 3276cfeb17 | |||
| 82cf5a3052 | |||
| 5d055ad521 | |||
| 93b276875e | |||
| 205e9eab26 | |||
| fe6f86593e | |||
| a7835468a1 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 3–6 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`.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
32
internal/db/migrations/005_catalog_power.sql
Normal file
32
internal/db/migrations/005_catalog_power.sql
Normal 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;
|
||||
87
internal/db/migrations/006_fix_power_hubs.sql
Normal file
87
internal/db/migrations/006_fix_power_hubs.sql
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", "", "")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user