merge: slice 3 — IO markers + cable-type editing UI
picasso shipped (3 commits @ a3f0586):
- internal/db/io_markers.go: project-scoped CRUD, cross-project FK rejection
- internal/server/io_markers.go: handlers under /api/projects/:pid/io-markers
- web/static: +IO tool with click-place, diamond rendering (SVG polygon),
drag, inspector for IO + cable-type, interactive legend with native
colour-picker + delete-blocked-on-use, '+ Type' modal, 'used by N
cables' counter
37 store tests green with -race.
This commit is contained in:
180
internal/db/io_markers.go
Normal file
180
internal/db/io_markers.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IOMarker is a wall-outlet terminator inside a project. Mostly Power
|
||||
// by convention; the schema doesn't enforce it.
|
||||
type IOMarker struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
FrameID *int64 `json:"frame_id"`
|
||||
Label string `json:"label"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IOMarkerCreate is the create-shape.
|
||||
type IOMarkerCreate struct {
|
||||
FrameID *int64
|
||||
Label string
|
||||
X float64
|
||||
Y float64
|
||||
}
|
||||
|
||||
// IOMarkerUpdate is the partial-update shape. project_id deliberately not
|
||||
// settable; frame_id uses the same tri-state shape as DeviceUpdate.FrameID.
|
||||
type IOMarkerUpdate struct {
|
||||
Label *string
|
||||
FrameID FrameRef
|
||||
X *float64
|
||||
Y *float64
|
||||
}
|
||||
|
||||
// CreateIOMarker inserts a new IO marker. If frame_id is set, it must
|
||||
// reference a frame in the same project.
|
||||
func (s *Store) CreateIOMarker(projectID int64, m IOMarkerCreate) (*IOMarker, error) {
|
||||
label := strings.TrimSpace(m.Label)
|
||||
if label == "" {
|
||||
label = "IO"
|
||||
}
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.FrameID != nil {
|
||||
if _, err := s.GetFrame(projectID, *m.FrameID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *m.FrameID, projectID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO io_markers (project_id, frame_id, label, x, y)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
projectID, nullableInt64(m.FrameID), label, m.X, m.Y,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetIOMarker(projectID, id)
|
||||
}
|
||||
|
||||
// GetIOMarker loads an IO marker, project-scoped.
|
||||
func (s *Store) GetIOMarker(projectID, id int64) (*IOMarker, error) {
|
||||
var m IOMarker
|
||||
var frame sql.NullInt64
|
||||
var ex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
|
||||
FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y, &ex, &m.CreatedAt, &m.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
m.FrameID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
m.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ListIOMarkers returns every IO marker in a project, ordered by creation.
|
||||
func (s *Store) ListIOMarkers(projectID int64) ([]IOMarker, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
|
||||
FROM io_markers WHERE project_id = ? ORDER BY created_at, id`, projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []IOMarker{}
|
||||
for rows.Next() {
|
||||
var m IOMarker
|
||||
var frame sql.NullInt64
|
||||
var ex sql.NullString
|
||||
if err := rows.Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y,
|
||||
&ex, &m.CreatedAt, &m.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
m.FrameID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
m.ExcalidrawID = &ex.String
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateIOMarker applies a partial update. project_id is locked; frame_id
|
||||
// tri-state mirrors DeviceUpdate.FrameID.
|
||||
func (s *Store) UpdateIOMarker(projectID, id int64, u IOMarkerUpdate) (*IOMarker, error) {
|
||||
cur, err := s.GetIOMarker(projectID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Label != nil {
|
||||
v := strings.TrimSpace(*u.Label)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("%w: label cannot be empty", ErrInvalidInput)
|
||||
}
|
||||
cur.Label = v
|
||||
}
|
||||
if u.X != nil {
|
||||
cur.X = *u.X
|
||||
}
|
||||
if u.Y != nil {
|
||||
cur.Y = *u.Y
|
||||
}
|
||||
if u.FrameID.Set {
|
||||
if u.FrameID.ID != nil {
|
||||
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cur.FrameID = u.FrameID.ID
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE io_markers
|
||||
SET frame_id = ?, label = ?, x = ?, y = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND project_id = ?`,
|
||||
nullableInt64(cur.FrameID), cur.Label, cur.X, cur.Y, id, projectID,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
return s.GetIOMarker(projectID, id)
|
||||
}
|
||||
|
||||
// DeleteIOMarker removes an IO marker from a project.
|
||||
func (s *Store) DeleteIOMarker(projectID, id int64) error {
|
||||
if _, err := s.GetIOMarker(projectID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
113
internal/db/io_markers_test.go
Normal file
113
internal/db/io_markers_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateIOMarker_DefaultsLabel(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{X: 10, Y: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if m.Label != "IO" {
|
||||
t.Errorf("default label = %q, want IO", m.Label)
|
||||
}
|
||||
if m.FrameID != nil {
|
||||
t.Errorf("frame_id = %v, want nil", m.FrameID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIOMarker_CustomLabel(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 0, Y: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if m.Label != "Wall A" {
|
||||
t.Errorf("label = %q, want Wall A", m.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIOMarker_CrossProjectFrameRejected(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p1, _ := s.CreateProject("LOFT", "", "")
|
||||
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||
_, err := s.CreateIOMarker(p1.ID, IOMarkerCreate{FrameID: &f2.ID, X: 0, Y: 0})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIOMarker_WrongProjectIsNotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p1, _ := s.CreateProject("LOFT", "", "")
|
||||
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||
m, _ := s.CreateIOMarker(p1.ID, IOMarkerCreate{X: 0, Y: 0})
|
||||
if _, err := s.GetIOMarker(p2.ID, m.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("cross-project GetIOMarker should be ErrNotFound; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateIOMarker_FrameIDTriState(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 0, Y: 0})
|
||||
|
||||
// Leave alone — passing a different X must not clear frame_id.
|
||||
nx := 99.0
|
||||
u1, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{X: &nx})
|
||||
if u1.FrameID == nil || *u1.FrameID != f.ID {
|
||||
t.Errorf("frame_id should still be set (Set=false); got %v", u1.FrameID)
|
||||
}
|
||||
|
||||
// Clear.
|
||||
u2, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{FrameID: FrameRef{Set: true, ID: nil}})
|
||||
if u2.FrameID != nil {
|
||||
t.Errorf("frame_id should be nil after clear; got %v", *u2.FrameID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFrame_SetsIOMarkerFrameIDToNull(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
|
||||
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 10, Y: 20})
|
||||
if m.FrameID == nil {
|
||||
t.Fatalf("pre-condition: io marker should have frame_id")
|
||||
}
|
||||
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
|
||||
t.Fatalf("delete frame: %v", err)
|
||||
}
|
||||
m2, _ := s.GetIOMarker(p.ID, m.ID)
|
||||
if m2.FrameID != nil {
|
||||
t.Errorf("io marker frame_id post-delete = %v, want nil (SET NULL)", m2.FrameID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot_PopulatesIOMarkers(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 10, Y: 20})
|
||||
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "UPS rear", X: 100, Y: 200})
|
||||
snap, err := s.Snapshot(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if len(snap.IOMarkers) != 2 {
|
||||
t.Errorf("io_markers len = %d, want 2", len(snap.IOMarkers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteIOMarker_NotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
if err := s.DeleteIOMarker(p.ID, 999); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ type Snapshot struct {
|
||||
Devices []Device `json:"devices"`
|
||||
Ports []any `json:"ports"`
|
||||
Cables []any `json:"cables"`
|
||||
IOMarkers []any `json:"io_markers"`
|
||||
IOMarkers []IOMarker `json:"io_markers"`
|
||||
Bundles []any `json:"bundles"`
|
||||
CableTypes []CableType `json:"cable_types"`
|
||||
}
|
||||
|
||||
@@ -167,13 +167,17 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ios, err := s.ListIOMarkers(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Snapshot{
|
||||
Project: *p,
|
||||
Frames: frames,
|
||||
Devices: devices,
|
||||
Ports: []any{},
|
||||
Cables: []any{},
|
||||
IOMarkers: []any{},
|
||||
IOMarkers: ios,
|
||||
Bundles: []any{},
|
||||
CableTypes: types,
|
||||
}, nil
|
||||
|
||||
109
internal/server/io_markers.go
Normal file
109
internal/server/io_markers.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
type ioMarkerCreate struct {
|
||||
FrameID *int64 `json:"frame_id,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
// ioMarkerPatch mirrors devicePatch's frame_id tri-state — see
|
||||
// devicePatch + parseFrameRef in frames_devices.go for the wire format.
|
||||
type ioMarkerPatch struct {
|
||||
Label *string `json:"label,omitempty"`
|
||||
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
||||
X *float64 `json:"x,omitempty"`
|
||||
Y *float64 `json:"y,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) listIOMarkers(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
ms, err := h.store.ListIOMarkers(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ms)
|
||||
}
|
||||
|
||||
func (h *handlers) createIOMarker(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body ioMarkerCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
m, err := h.store.CreateIOMarker(pid, db.IOMarkerCreate{
|
||||
FrameID: body.FrameID, Label: body.Label, X: body.X, Y: body.Y,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (h *handlers) patchIOMarker(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body ioMarkerPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
ref, err := parseFrameRef(body.FrameID)
|
||||
if err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), "frame_id must be an integer or null")
|
||||
return
|
||||
}
|
||||
m, err := h.store.UpdateIOMarker(pid, id, db.IOMarkerUpdate{
|
||||
Label: body.Label, FrameID: ref, X: body.X, Y: body.Y,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteIOMarker(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteIOMarker(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -45,6 +45,12 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
|
||||
|
||||
// IO markers (project-scoped) — wall-outlet terminators
|
||||
mux.HandleFunc("GET /api/projects/{pid}/io-markers", h.listIOMarkers)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/io-markers", h.createIOMarker)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/io-markers/{id}", h.deleteIOMarker)
|
||||
|
||||
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
||||
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
||||
// the file server already emits — without this, browsers cache aggressively
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
<ul class="tool-list">
|
||||
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
|
||||
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
|
||||
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -14,10 +14,13 @@
|
||||
* @typedef {{ id: number, project_id: number, frame_id: number|null,
|
||||
* name: string, color: string,
|
||||
* x: number, y: number, width: number, height: number }} Device
|
||||
* @typedef {{ id: number, project_id: number, frame_id: number|null,
|
||||
* label: string, x: number, y: number }} IOMarker
|
||||
*/
|
||||
|
||||
const API = "/api";
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/height)
|
||||
|
||||
const state = {
|
||||
/** @type {Project[]} */ projects: [],
|
||||
@@ -25,10 +28,11 @@ const state = {
|
||||
/** @type {Project | null} */ active: null,
|
||||
/** @type {Frame[]} */ frames: [],
|
||||
/** @type {Device[]} */ devices: [],
|
||||
/** @type {IOMarker[]} */ ioMarkers: [],
|
||||
activeTypeId: /** @type {number|null} */ (null),
|
||||
/** "frame" | "device" | null */
|
||||
/** "frame" | "device" | "io" | null */
|
||||
tool: /** @type {string|null} */ (null),
|
||||
/** @type {{kind: "frame"|"device", id: number} | null} */ selection: null,
|
||||
/** @type {{kind: "frame"|"device"|"io"|"cable_type", id: number} | null} */ selection: null,
|
||||
};
|
||||
|
||||
// ---------- API client ---------- //
|
||||
@@ -70,6 +74,10 @@ const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, bo
|
||||
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
|
||||
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
|
||||
|
||||
const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers`, body);
|
||||
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
|
||||
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
|
||||
|
||||
// ---------- DOM helpers ---------- //
|
||||
|
||||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||||
@@ -164,8 +172,13 @@ function renderLegend() {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// Click toggles activeTypeId AND moves the inspector to show the
|
||||
// cable type's details. If m clicks the already-active type the
|
||||
// active is cleared but the inspector still shows it (so m can
|
||||
// edit name/colour without an active draw mode getting in the way).
|
||||
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
|
||||
renderLegend();
|
||||
state.selection = { kind: "cable_type", id: t.id };
|
||||
render();
|
||||
});
|
||||
ul.append(li);
|
||||
}
|
||||
@@ -191,8 +204,10 @@ function renderEmptyHint() {
|
||||
function renderCanvas() {
|
||||
const gFrames = $("#canvas-frames");
|
||||
const gDevices = $("#canvas-devices");
|
||||
const gIO = $("#canvas-io");
|
||||
gFrames.innerHTML = "";
|
||||
gDevices.innerHTML = "";
|
||||
gIO.innerHTML = "";
|
||||
|
||||
for (const f of state.frames) {
|
||||
const g = svgEl("g", { "data-frame-id": f.id });
|
||||
@@ -234,6 +249,31 @@ function renderCanvas() {
|
||||
gDevices.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
|
||||
}
|
||||
|
||||
for (const m of state.ioMarkers) {
|
||||
const g = svgEl("g", { "data-io-id": m.id });
|
||||
// Diamond = a square rotated 45° around its centre. Using a <rect>
|
||||
// with rotate(45 cx cy) is the easiest hit-shape that still respects
|
||||
// x/y as the rotated bounding box.
|
||||
const cx = m.x + IO_SIZE / 2;
|
||||
const cy = m.y + IO_SIZE / 2;
|
||||
const rect = svgEl("rect", {
|
||||
x: m.x, y: m.y, width: IO_SIZE, height: IO_SIZE,
|
||||
class: "io-marker svg-draggable",
|
||||
transform: `rotate(45 ${cx} ${cy})`,
|
||||
});
|
||||
if (state.selection?.kind === "io" && state.selection.id === m.id) {
|
||||
rect.classList.add("selected");
|
||||
}
|
||||
const label = svgEl("text", {
|
||||
x: cx, y: cy + IO_SIZE * 0.85,
|
||||
class: "io-marker-label",
|
||||
});
|
||||
label.textContent = m.label;
|
||||
g.append(rect, label);
|
||||
gIO.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "io", m.id));
|
||||
}
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
@@ -242,10 +282,12 @@ function renderInspector() {
|
||||
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||||
return;
|
||||
}
|
||||
if (state.selection.kind === "frame") {
|
||||
renderInspectorFrame(body, state.selection.id);
|
||||
} else {
|
||||
renderInspectorDevice(body, state.selection.id);
|
||||
switch (state.selection.kind) {
|
||||
case "frame": return renderInspectorFrame(body, state.selection.id);
|
||||
case "device": return renderInspectorDevice(body, state.selection.id);
|
||||
case "io": return renderInspectorIO(body, state.selection.id);
|
||||
case "cable_type": return renderInspectorCableType(body, state.selection.id);
|
||||
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +295,7 @@ function renderInspectorFrame(body, id) {
|
||||
const f = state.frames.find((x) => x.id === id);
|
||||
if (!f) { body.innerHTML = ""; return; }
|
||||
const deviceCount = state.devices.filter((d) => d.frame_id === f.id).length;
|
||||
const ioCount = state.ioMarkers.filter((m) => m.frame_id === f.id).length;
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Frame</p>
|
||||
<label class="field">
|
||||
@@ -265,6 +308,7 @@ function renderInspectorFrame(body, id) {
|
||||
<dt>w</dt><dd id="frm-w"></dd>
|
||||
<dt>h</dt><dd id="frm-h"></dd>
|
||||
<dt>devices</dt><dd id="frm-count"></dd>
|
||||
<dt>IO</dt><dd id="frm-io-count"></dd>
|
||||
</dl>
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
|
||||
@@ -276,6 +320,7 @@ function renderInspectorFrame(body, id) {
|
||||
body.querySelector("#frm-w").textContent = f.width.toFixed(0);
|
||||
body.querySelector("#frm-h").textContent = f.height.toFixed(0);
|
||||
body.querySelector("#frm-count").textContent = String(deviceCount);
|
||||
body.querySelector("#frm-io-count").textContent = String(ioCount);
|
||||
|
||||
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
|
||||
if (!state.active) return;
|
||||
@@ -286,9 +331,10 @@ function renderInspectorFrame(body, id) {
|
||||
|
||||
body.querySelector("#frm-delete").addEventListener("click", () => {
|
||||
if (!state.active) return;
|
||||
if (!confirm(`Delete frame "${f.name}"? Its devices stay but lose their frame.`)) return;
|
||||
if (!confirm(`Delete frame "${f.name}"? Its devices and IO markers stay but lose their frame.`)) return;
|
||||
deleteFrame(state.active.id, f.id).then(() => {
|
||||
state.frames = state.frames.filter((x) => x.id !== f.id);
|
||||
for (const m of state.ioMarkers) if (m.frame_id === f.id) m.frame_id = null;
|
||||
for (const d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
|
||||
state.selection = null;
|
||||
render();
|
||||
@@ -361,6 +407,120 @@ function renderInspectorDevice(body, id) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspectorIO(body, id) {
|
||||
const m = state.ioMarkers.find((x) => x.id === id);
|
||||
if (!m) { body.innerHTML = ""; return; }
|
||||
const frame = m.frame_id ? state.frames.find((f) => f.id === m.frame_id) : null;
|
||||
body.innerHTML = `
|
||||
<p class="section-title">IO marker</p>
|
||||
<label class="field">
|
||||
<span>Label</span>
|
||||
<input class="inline-input" id="io-label" value="" />
|
||||
</label>
|
||||
<dl>
|
||||
<dt>x</dt><dd id="io-x"></dd>
|
||||
<dt>y</dt><dd id="io-y"></dd>
|
||||
<dt>frame</dt><dd id="io-frame"></dd>
|
||||
</dl>
|
||||
<p class="muted" style="font-size:12px">
|
||||
Wall-outlet terminator. Power-by-convention; a future cable terminating
|
||||
here means "plugged into a socket outside the diagram".
|
||||
</p>
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="io-delete">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#io-label").value = m.label;
|
||||
body.querySelector("#io-x").textContent = m.x.toFixed(0);
|
||||
body.querySelector("#io-y").textContent = m.y.toFixed(0);
|
||||
body.querySelector("#io-frame").textContent = frame ? frame.name : "—";
|
||||
|
||||
bindDebouncedRename(body.querySelector("#io-label"), async (label) => {
|
||||
if (!state.active) return;
|
||||
const updated = await patchIOMarker(state.active.id, m.id, { label });
|
||||
Object.assign(m, updated);
|
||||
renderCanvas();
|
||||
});
|
||||
|
||||
body.querySelector("#io-delete").addEventListener("click", () => {
|
||||
if (!state.active) return;
|
||||
if (!confirm(`Delete IO marker "${m.label}"?`)) return;
|
||||
deleteIOMarker(state.active.id, m.id).then(() => {
|
||||
state.ioMarkers = state.ioMarkers.filter((x) => x.id !== m.id);
|
||||
state.selection = null;
|
||||
render();
|
||||
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspectorCableType(body, id) {
|
||||
const t = state.cableTypes.find((x) => x.id === id);
|
||||
if (!t) { body.innerHTML = ""; return; }
|
||||
// The "used by N cables" counter is purely informational in slice 3.
|
||||
// Slice 7+ will populate state.cables; until then we surface 0.
|
||||
const usedBy = 0;
|
||||
const banner = `
|
||||
<p class="banner" style="margin: 0 0 12px 0">
|
||||
Cable types are shared across all projects. Renaming or recolouring
|
||||
affects every project.
|
||||
</p>
|
||||
`;
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Cable type</p>
|
||||
${banner}
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input class="inline-input" id="ct-name" value="" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Colour</span>
|
||||
<input type="color" class="inline-input" id="ct-color" />
|
||||
</label>
|
||||
<dl>
|
||||
<dt>used by</dt><dd id="ct-used"></dd>
|
||||
</dl>
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="ct-delete">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#ct-name").value = t.name;
|
||||
body.querySelector("#ct-color").value = t.color;
|
||||
body.querySelector("#ct-used").textContent = `${usedBy} cable${usedBy === 1 ? "" : "s"}`;
|
||||
|
||||
bindDebouncedRename(body.querySelector("#ct-name"), async (name) => {
|
||||
const updated = await patchCableType(t.id, { name });
|
||||
Object.assign(t, updated);
|
||||
render();
|
||||
});
|
||||
|
||||
body.querySelector("#ct-color").addEventListener("change", async (e) => {
|
||||
const color = /** @type {HTMLInputElement} */ (e.target).value;
|
||||
try {
|
||||
const updated = await patchCableType(t.id, { color });
|
||||
Object.assign(t, updated);
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(`Colour update failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
body.querySelector("#ct-delete").addEventListener("click", async () => {
|
||||
if (!confirm(`Delete cable type "${t.name}"? Blocked if any cable uses it.`)) return;
|
||||
try {
|
||||
await deleteCableType(t.id);
|
||||
state.cableTypes = await listCableTypes();
|
||||
if (state.activeTypeId === t.id) state.activeTypeId = null;
|
||||
state.selection = null;
|
||||
render();
|
||||
} catch (err) {
|
||||
const n = err.details?.in_use_by_cables;
|
||||
alert(n != null
|
||||
? `Cannot delete "${t.name}" — in use by ${n} cable${n === 1 ? "" : "s"}.`
|
||||
: `Delete failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindDebouncedRename(input, persist) {
|
||||
let timer = null;
|
||||
input.addEventListener("input", () => {
|
||||
@@ -395,6 +555,7 @@ async function activateProject(id) {
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
state.ioMarkers = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
@@ -405,6 +566,7 @@ async function activateProject(id) {
|
||||
state.active = snap.project;
|
||||
state.frames = snap.frames || [];
|
||||
state.devices = snap.devices || [];
|
||||
state.ioMarkers = snap.io_markers || [];
|
||||
state.cableTypes = snap.cable_types || [];
|
||||
state.selection = null;
|
||||
setActiveInURL(id);
|
||||
@@ -414,6 +576,7 @@ async function activateProject(id) {
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
state.ioMarkers = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
} else {
|
||||
@@ -447,6 +610,7 @@ function bindTools() {
|
||||
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
|
||||
else if (e.key === "f" || e.key === "F") armTool("frame");
|
||||
else if (e.key === "d" || e.key === "D") armTool("device");
|
||||
else if (e.key === "i" || e.key === "I") armTool("io");
|
||||
});
|
||||
|
||||
// Canvas-level pointerdown handles tool activation + selection clearing.
|
||||
@@ -483,10 +647,15 @@ function onCanvasPointerDown(e) {
|
||||
placeDeviceAt(p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "io") {
|
||||
e.preventDefault();
|
||||
placeIOMarkerAt(p);
|
||||
return;
|
||||
}
|
||||
|
||||
// No tool armed: clicks that started on a device/frame go to their
|
||||
// No tool armed: clicks that started on a device/frame/io go to their
|
||||
// own handlers (drag / select). Leave them alone.
|
||||
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id]")) return;
|
||||
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id]")) return;
|
||||
|
||||
// Plain canvas click = clear selection.
|
||||
if (state.selection) { state.selection = null; render(); }
|
||||
@@ -566,6 +735,30 @@ async function placeDeviceAt(p) {
|
||||
}
|
||||
}
|
||||
|
||||
async function placeIOMarkerAt(p) {
|
||||
if (!state.active) return;
|
||||
armTool(null);
|
||||
const x = p.x - IO_SIZE / 2;
|
||||
const y = p.y - IO_SIZE / 2;
|
||||
// Label is optional; a blank prompt commits with the default "IO"
|
||||
// (server-side fallback in CreateIOMarker). Esc cancels.
|
||||
const label = await promptInline("Outlet label (Enter for 'IO')", p.x, p.y - IO_SIZE);
|
||||
if (label === null || !state.active) return;
|
||||
const frame = frameAt(p.x, p.y);
|
||||
try {
|
||||
const m = await createIOMarker(state.active.id, {
|
||||
label: label || undefined,
|
||||
x, y,
|
||||
frame_id: frame ? frame.id : undefined,
|
||||
});
|
||||
state.ioMarkers.push(m);
|
||||
state.selection = { kind: "io", id: m.id };
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(`Create IO marker failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- inline namer (foreignObject overlay) ---------- //
|
||||
|
||||
let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null);
|
||||
@@ -625,23 +818,30 @@ function startDrag(e, kind, id) {
|
||||
|
||||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||||
const start = svgPoint(e);
|
||||
/** @type {Frame|Device|undefined} */
|
||||
const obj = kind === "frame"
|
||||
? state.frames.find((f) => f.id === id)
|
||||
: state.devices.find((d) => d.id === id);
|
||||
/** @type {Frame|Device|IOMarker|undefined} */
|
||||
let obj;
|
||||
if (kind === "frame") obj = state.frames.find((f) => f.id === id);
|
||||
else if (kind === "device") obj = state.devices.find((d) => d.id === id);
|
||||
else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id);
|
||||
if (!obj) return;
|
||||
const startX = obj.x;
|
||||
const startY = obj.y;
|
||||
|
||||
// For frame drags, remember the contained devices + their offsets so
|
||||
// they follow the frame visually + persist on release.
|
||||
// For frame drags, remember the contained devices + IO markers + their
|
||||
// offsets so they follow the frame visually + persist on release.
|
||||
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
|
||||
let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]);
|
||||
if (kind === "frame") {
|
||||
for (const d of state.devices) {
|
||||
if (d.frame_id === obj.id) {
|
||||
trackedDevices.push({ d, sx: d.x, sy: d.y });
|
||||
}
|
||||
}
|
||||
for (const m of state.ioMarkers) {
|
||||
if (m.frame_id === obj.id) {
|
||||
trackedIOs.push({ m, sx: m.x, sy: m.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.currentTarget.classList.add("dragging");
|
||||
@@ -658,6 +858,7 @@ function startDrag(e, kind, id) {
|
||||
obj.y = startY + dy;
|
||||
if (kind === "frame") {
|
||||
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
|
||||
for (const t of trackedIOs) { t.m.x = t.sx + dx; t.m.y = t.sy + dy; }
|
||||
}
|
||||
renderCanvas();
|
||||
};
|
||||
@@ -674,12 +875,14 @@ function startDrag(e, kind, id) {
|
||||
if (kind === "frame") {
|
||||
const f = /** @type {Frame} */ (obj);
|
||||
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
|
||||
// Persist contained devices too.
|
||||
await Promise.all(
|
||||
trackedDevices.map((t) =>
|
||||
// Persist contained devices + IO markers too.
|
||||
await Promise.all([
|
||||
...trackedDevices.map((t) =>
|
||||
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
|
||||
);
|
||||
} else {
|
||||
...trackedIOs.map((t) =>
|
||||
patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })),
|
||||
]);
|
||||
} else if (kind === "device") {
|
||||
const d = /** @type {Device} */ (obj);
|
||||
// Recompute frame_id from drop point (centre of device).
|
||||
const cx = d.x + d.width / 2;
|
||||
@@ -692,6 +895,18 @@ function startDrag(e, kind, id) {
|
||||
d.frame_id = newFrameID;
|
||||
}
|
||||
await patchDevice(state.active.id, d.id, patchBody);
|
||||
} else /* io */ {
|
||||
const m = /** @type {IOMarker} */ (obj);
|
||||
const cx = m.x + IO_SIZE / 2;
|
||||
const cy = m.y + IO_SIZE / 2;
|
||||
const targetFrame = frameAt(cx, cy);
|
||||
const newFrameID = targetFrame ? targetFrame.id : null;
|
||||
const patchBody = { x: m.x, y: m.y };
|
||||
if ((m.frame_id ?? null) !== newFrameID) {
|
||||
patchBody.frame_id = newFrameID;
|
||||
m.frame_id = newFrameID;
|
||||
}
|
||||
await patchIOMarker(state.active.id, m.id, patchBody);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Save failed: ${err.message}`);
|
||||
|
||||
@@ -211,7 +211,32 @@ body {
|
||||
.canvas-wrap.tool-frame #canvas,
|
||||
.canvas-wrap.tool-frame #canvas *,
|
||||
.canvas-wrap.tool-device #canvas,
|
||||
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
|
||||
.canvas-wrap.tool-device #canvas *,
|
||||
.canvas-wrap.tool-io #canvas,
|
||||
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; }
|
||||
|
||||
/* IO markers — diamonds. Power-by-convention, so the default fill is
|
||||
the Power cable_type colour (#e03131). Rotated 45° rect is the
|
||||
easiest way to draw a diamond that still hit-tests at the rotated
|
||||
bounds (a <polygon> would also work; rect-with-rotate keeps the
|
||||
same DOM shape as device/frame so the drag helpers reuse). */
|
||||
.io-marker {
|
||||
fill: var(--danger);
|
||||
fill-opacity: 0.18;
|
||||
stroke: var(--danger);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.io-marker.selected,
|
||||
.io-marker:hover { stroke-width: 2.5; }
|
||||
.io-marker-label {
|
||||
fill: var(--danger);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rubber-band {
|
||||
fill: rgba(25, 113, 194, 0.08);
|
||||
|
||||
Reference in New Issue
Block a user