Files
ausboss-ComfyUI-AusBoss/js/shared/workflow_switches.mjs
T

746 lines
30 KiB
JavaScript

// Workflow Switches: the decisions behind js/workflow_switches/index.js.
// Which nodes a switch holds, whether it reads on, off or mixed, which rows
// the card lists and in what order, and which node modes a click changes. No
// DOM and no app import, so tests/workflow_switches.test.mjs runs it under
// node:test.
//
// A switch is either one the user made from picked nodes and groups (stored
// in the node's properties, by id) or a group listed on its own. A made
// switch can also change settings instead of turning nodes off: it keeps
// what chosen nodes' controls held when "on" was saved and when "off" was
// saved, and switches the values that differ. The truth lives in the nodes'
// own modes and values. Nothing here remembers what a switch was; every
// answer is read fresh from the graph, which is why a saved workflow, an
// undo or a change made by hand elsewhere always shows right.
// LiteGraph's node modes. Mute is the frontend's "never".
export const MODE_ALWAYS = 0;
export const MODE_NEVER = 2;
export const MODE_BYPASS = 4;
export const SETTINGS_PROPERTY = "ausboss_workflow_switches";
export const TITLE_MAX = 60;
export const SWITCHES_MAX = 40;
export const MEMBERS_MAX = 400;
// A switch that changes settings: how many nodes it may remember, how many
// controls of each, and how long a remembered text may be.
export const VALUE_NODES_MAX = 40;
export const VALUE_WIDGETS_MAX = 80;
export const VALUE_TEXT_MAX = 20000;
export const DEFAULT_SETTINGS = Object.freeze({
off: "bypass", // what a switched-off part does: "bypass" | "mute"
groups: "all", // which groups get a row of their own: "all" | "numbered" | "matching" | "none"
match: "", // comma-separated title text, read when groups is "matching"
order: "canvas", // group rows: "canvas" (the way the workflow reads) | "title"
exclusive: false, // one at a time: switching a row on switches the other rows off
switches: Object.freeze([]), // the made switches: { id, title, nodes, groups, on, off }
});
const CHOICES = {
off: ["bypass", "mute"],
groups: ["all", "numbered", "matching", "none"],
order: ["canvas", "title"],
};
export function cleanTitle(title) {
return String(title ?? "").replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
}
// Node and group ids as a workflow file stores them: numbers, or text for a
// frontend that names them. A whole number held as text ("12", which is how
// some frontends keep a node's id while the graph is open) is stored as the
// number, so a switch reads the same whichever one saved it. Anything else
// is dropped, and so is a repeat.
export function cleanIds(list, limit = MEMBERS_MAX) {
const seen = new Set();
const ids = [];
for (const raw of Array.isArray(list) ? list : []) {
let id = null;
if (typeof raw === "number" && Number.isFinite(raw)) id = raw;
else if (typeof raw === "string" && /^(0|[1-9]\d{0,14})$/.test(raw.trim())) id = Number(raw.trim());
else if (typeof raw === "string" && raw.trim()) id = raw.trim();
if (id === null || seen.has(String(id))) continue;
seen.add(String(id));
ids.push(id);
if (ids.length >= limit) break;
}
return ids;
}
// A value a control can hold and a workflow file can store: text, a number,
// or on/off. Anything else (and text past the limit) is not remembered.
function cleanValue(value) {
if (typeof value === "boolean") return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.length <= VALUE_TEXT_MAX) return value;
return undefined;
}
// One side of a switch that changes settings: for each of its nodes (by id)
// what the node's controls held when that side was saved, by widget name.
export function cleanSnapshots(raw) {
const sides = {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return sides;
let nodes = 0;
for (const [id, widgets] of Object.entries(raw)) {
if (!widgets || typeof widgets !== "object" || Array.isArray(widgets)) continue;
const kept = {};
let count = 0;
for (const [name, value] of Object.entries(widgets)) {
const clean = cleanValue(value);
if (clean === undefined || !name) continue;
kept[name] = clean;
count += 1;
if (count >= VALUE_WIDGETS_MAX) break;
}
if (!count) continue;
sides[String(id)] = kept;
nodes += 1;
if (nodes >= VALUE_NODES_MAX) break;
}
return sides;
}
// The made switches, whatever a saved workflow or a hand edit left there.
// Every switch keeps a small whole-number id of its own; a missing or
// repeated one is replaced, so two rows are never told apart by title. A
// node whose values a switch changes is not also turned off by it.
export function normalizeSwitches(raw) {
const switches = [];
const used = new Set();
for (const item of Array.isArray(raw) ? raw : []) {
if (!item || typeof item !== "object") continue;
let id = Number(item.id);
if (!Number.isInteger(id) || id < 1 || used.has(id)) {
id = 1;
while (used.has(id)) id += 1;
}
used.add(id);
const on = cleanSnapshots(item.on);
const off = cleanSnapshots(item.off);
switches.push({
id,
title: cleanTitle(item.title) || `Switch ${id}`,
nodes: cleanIds(item.nodes).filter((member) => !(String(member) in on) && !(String(member) in off)),
groups: cleanIds(item.groups),
on,
off,
});
if (switches.length >= SWITCHES_MAX) break;
}
return switches;
}
// Settings from a node property: unknown values fall back to the defaults,
// never throw.
export function normalizeSettings(raw) {
const source = raw && typeof raw === "object" ? raw : {};
const settings = { ...DEFAULT_SETTINGS };
for (const [key, allowed] of Object.entries(CHOICES)) {
if (allowed.includes(source[key])) settings[key] = source[key];
}
if (typeof source.match === "string") settings.match = source.match.slice(0, 200);
if (typeof source.exclusive === "boolean") settings.exclusive = source.exclusive;
settings.switches = normalizeSwitches(source.switches);
return settings;
}
export function offMode(settings) {
return settings?.off === "mute" ? MODE_NEVER : MODE_BYPASS;
}
// A node runs unless it is muted or bypassed.
export function isRunning(mode) {
return mode !== MODE_NEVER && mode !== MODE_BYPASS;
}
// ---------- geometry ----------
// Half-open, like LiteGraph's own isInRect: a point on a box's right or
// bottom edge is outside it, so two boxes that touch never share a node.
export function isInRect(x, y, rect) {
return x >= rect[0] && x < rect[0] + rect[2] && y >= rect[1] && y < rect[1] + rect[3];
}
// The rule the frontend uses to decide what moves with a group: a node
// belongs to every group whose box holds the centre of the node's box.
export function centreInside(rect, bounds) {
return isInRect(bounds[0] + bounds[2] * 0.5, bounds[1] + bounds[3] * 0.5, rect);
}
function usableRect(rect) {
return (
rect != null && typeof rect.length === "number" && rect.length >= 4
&& [0, 1, 2, 3].every((index) => Number.isFinite(Number(rect[index])))
&& (Number(rect[2]) > 0 || Number(rect[3]) > 0)
);
}
function toRect(rect) {
return [Number(rect[0]), Number(rect[1]), Number(rect[2]), Number(rect[3])];
}
// A node's box as LiteGraph measures it: the body plus its title bar. The
// canvas re-measures every node on every frame it draws (the same pass that
// decides which nodes are visible), so the cached boundingRect is current;
// a node never measured yet is measured here from its position and size.
export function nodeBounds(node, { titleHeight = 30, collapsedWidth = 80 } = {}) {
if (usableRect(node?.boundingRect)) return toRect(node.boundingRect);
const pos = node?.pos ?? [0, 0];
const size = node?.size ?? [0, 0];
// Title modes: 1 = no title, 2 = transparent title; neither adds a bar.
const bar = node?.title_mode === 1 || node?.title_mode === 2 ? 0 : titleHeight;
const x = Number(pos[0]) || 0;
const y = (Number(pos[1]) || 0) - bar;
if (node?.flags?.collapsed) return [x, y, Number(node._collapsed_width) || collapsedWidth, titleHeight];
return [x, y, Number(size[0]) || 0, (Number(size[1]) || 0) + bar];
}
export function groupBounds(group) {
for (const rect of [group?.boundingRect, group?._bounding]) {
if (usableRect(rect)) return toRect(rect);
}
const pos = group?.pos ?? [0, 0];
const size = group?.size ?? [0, 0];
return [Number(pos[0]) || 0, Number(pos[1]) || 0, Number(size[0]) || 0, Number(size[1]) || 0];
}
// The smallest box around several boxes, or null for none: what the frame
// button brings into view for a switch whose nodes sit apart.
export function unionRect(rects) {
let box = null;
for (const rect of rects ?? []) {
if (!usableRect(rect)) continue;
const [x, y, w, h] = toRect(rect);
if (!box) box = [x, y, x + w, y + h];
else box = [Math.min(box[0], x), Math.min(box[1], y), Math.max(box[2], x + w), Math.max(box[3], y + h)];
}
return box ? [box[0], box[1], box[2] - box[0], box[3] - box[1]] : null;
}
// What the frame button brings into view. The canvas fits a box to `fill` of
// the view, which would zoom far in on a part of two small nodes. A box that
// would be shown larger than life is grown around its centre until it shows
// at natural size; a big part still fits whole.
export function frameBounds(rect, view, fill = 0.8) {
const [x, y, w, h] = toRect(rect);
const minW = Number(view?.[0]) * fill;
const minH = Number(view?.[1]) * fill;
if (!(w > 0) || !(h > 0) || !(minW > 0) || !(minH > 0) || w >= minW || h >= minH) return [x, y, w, h];
const grow = Math.min(minW / w, minH / h);
return [x - (w * grow - w) / 2, y - (h * grow - h) / 2, w * grow, h * grow];
}
// ---------- reading a graph ----------
// Whether a node's mode changes what a run does. A subgraph node's does
// (while it is off nothing inside it runs) even though the frontend calls it
// virtual; other virtual nodes - notes, primitives, reroute nodes - never
// run, and neither does a node with no outputs that is not an output node (a
// Workflow Note, a Run Timer). Those still switch with their part, but they
// never make it read mixed.
export function modeMatters(node) {
if (node?.isSubgraphNode?.() === true) return true;
if (node?.isVirtualNode) return false;
const definition = node?.constructor?.nodeData;
if (definition && !definition.output_node && !(node.outputs?.length > 0)) return false;
return true;
}
// What a switch shows. Only nodes whose mode changes a run are counted (a
// note inside a group never makes it read "mixed"); a part of nothing but
// such nodes is read from all of them, and one holding no node at all is
// "empty".
//
// A switch that changes settings adds one unit per setting (`pairs`, see
// readPairs): a setting at its "on" value counts as on, at its "off" value
// as off, and at anything else as neither, which reads mixed.
export function partState(members, counts = () => true, pairs = []) {
const counted = members.filter(counts);
const pool = counted.length || pairs.length ? counted : members;
const total = pool.length + pairs.length;
if (!total) return { state: "empty", on: 0, total: 0 };
const running = pool.filter((node) => isRunning(node.mode)).length;
const on = running + pairs.filter((pair) => pair.now === "on").length;
const off = pool.length - running + pairs.filter((pair) => pair.now === "off").length;
const state = on === total ? "on" : off === total ? "off" : "mixed";
return { state, on, total };
}
// ---------- switches that change settings ----------
// What a node's own controls hold right now, by widget name: what "save as
// on" remembers. A card or panel that is not saved with the workflow has no
// value of its own and is left out.
export function snapshotNode(node) {
const values = {};
let count = 0;
for (const widget of node?.widgets ?? []) {
if (!widget || widget.serialize === false || typeof widget.name !== "string" || !widget.name) continue;
const clean = cleanValue(widget.value);
if (clean === undefined) continue;
values[widget.name] = clean;
count += 1;
if (count >= VALUE_WIDGETS_MAX) break;
}
return values;
}
// A LoRA stack keeps every row in one text value: a list of rows, each with
// a file name and whether it is on. Returns the rows, or null for any other
// text.
export function stackRows(value) {
if (typeof value !== "string" || !value.startsWith("[")) return null;
let rows;
try {
rows = JSON.parse(value);
} catch {
return null;
}
if (!Array.isArray(rows) || !rows.length) return null;
const isRow = (row) => row && typeof row === "object" && typeof row.name === "string" && typeof row.enabled === "boolean";
return rows.every(isRow) ? rows : null;
}
function rowSetting(row) {
const strength = Number(row.strength);
return { enabled: Boolean(row.enabled), strength: Number.isFinite(strength) ? strength : null };
}
function sameSetting(a, b) {
return a.enabled === b.enabled && a.strength === b.strength;
}
// The settings a switch changes: every control that held one value when "on"
// was saved and another when "off" was saved. A LoRA stack is compared row
// by row, by file name, so the switch moves only the rows that differ and
// leaves whatever else was changed in the stack since. Returns
// { node, widget, on, off } and, for a stack row, { ..., row }.
export function valuePairs(entry) {
const pairs = [];
for (const [id, on] of Object.entries(entry?.on ?? {})) {
const off = entry?.off?.[id];
if (!off) continue;
for (const [widget, onValue] of Object.entries(on)) {
if (!(widget in off) || off[widget] === onValue) continue;
const offValue = off[widget];
const onRows = stackRows(onValue);
const offRows = stackRows(offValue);
if (!onRows || !offRows) {
pairs.push({ node: id, widget, on: onValue, off: offValue });
continue;
}
for (const row of onRows) {
const other = offRows.find((candidate) => candidate.name === row.name);
if (!other) continue;
const [a, b] = [rowSetting(row), rowSetting(other)];
if (!sameSetting(a, b)) pairs.push({ node: id, widget, row: row.name, on: a, off: b });
}
}
}
return pairs;
}
// Where a setting stands now: at its on value, its off value, or neither.
export function pairNow(pair, current) {
if (pair.row === undefined) return current === pair.on ? "on" : current === pair.off ? "off" : "other";
const row = stackRows(current)?.find((candidate) => candidate.name === pair.row);
if (!row) return "other";
const now = rowSetting(row);
return sameSetting(now, pair.on) ? "on" : sameSetting(now, pair.off) ? "off" : "other";
}
// The value a control should hold for one side of a switch. For a stack row
// it is the stack as it is now with that one row changed.
export function pairValue(pair, side, current) {
const want = side === "on" ? pair.on : pair.off;
if (pair.row === undefined) return want;
const rows = stackRows(current);
if (!rows) return current;
return JSON.stringify(rows.map((row) => {
if (row.name !== pair.row) return row;
return want.strength === null ? { ...row, enabled: want.enabled } : { ...row, enabled: want.enabled, strength: want.strength };
}));
}
// A switch's settings as they stand on the graph: each pair with its node,
// its control and where it is now. A pair whose node or control is gone is
// left out.
export function readPairs(entry, nodeById) {
const pairs = [];
for (const pair of valuePairs(entry)) {
const live = nodeById.get(String(pair.node))?.node;
const control = live?.widgets?.find((widget) => widget?.name === pair.widget);
if (!control) continue;
pairs.push({ ...pair, live, control, now: pairNow(pair, control.value) });
}
return pairs;
}
// Every node a switch may touch, with its box. `skip` leaves nodes out
// entirely (a Workflow Switches node never switches itself or another one).
export function placeNodes(nodes, { skip = () => false, measure = {} } = {}) {
const placed = [];
for (const node of nodes ?? []) {
if (!node || skip(node)) continue;
placed.push({ node, rect: nodeBounds(node, measure) });
}
return placed;
}
// Every group of a graph with the nodes inside it and its state. `counts`
// says whose mode decides the state (see partState).
export function readGroups(groups, placed, { counts = () => true } = {}) {
return [...(groups ?? [])].filter(Boolean).map((group) => {
const rect = groupBounds(group);
const members = placed.filter((entry) => centreInside(rect, entry.rect)).map((entry) => entry.node);
return {
kind: "group",
group,
rect,
title: String(group.title ?? ""),
color: typeof group.color === "string" && group.color ? group.color : null,
members,
...partState(members, counts),
};
});
}
// The made switches with the nodes they hold right now: the picked nodes
// that still exist, plus whatever sits inside a picked group at this moment.
// An id whose node or group is gone is passed over, never removed here: a
// graph that is still loading has not got all its nodes yet.
export function readSwitches(switches, placed, groupRows, { counts = () => true } = {}) {
const nodeById = new Map(placed.map((entry) => [String(entry.node.id), entry]));
const groupById = new Map();
for (const row of groupRows ?? []) {
const id = row.group?.id;
if (id !== undefined && id !== null && !groupById.has(String(id))) groupById.set(String(id), row);
}
return (switches ?? []).map((entry) => {
const members = [];
const seen = new Set();
const rects = [];
let gone = 0;
for (const id of entry.nodes ?? []) {
const hit = nodeById.get(String(id));
if (!hit) {
gone += 1;
continue;
}
if (seen.has(hit.node)) continue;
seen.add(hit.node);
members.push(hit.node);
rects.push(hit.rect);
}
for (const id of entry.groups ?? []) {
const row = groupById.get(String(id));
if (!row) {
gone += 1;
continue;
}
rects.push(row.rect);
for (const node of row.members) {
if (seen.has(node)) continue;
seen.add(node);
members.push(node);
}
}
// The nodes whose settings it changes: listed, framed and selected with
// the rest, but never turned off by it.
const valueNodes = [];
const sides = new Set([...Object.keys(entry.on ?? {}), ...Object.keys(entry.off ?? {})]);
for (const id of sides) {
const hit = nodeById.get(id);
if (!hit) {
gone += 1;
continue;
}
valueNodes.push(hit.node);
rects.push(hit.rect);
}
const pairs = readPairs(entry, nodeById);
return {
kind: "switch",
entry,
rect: unionRect(rects),
title: entry.title,
color: null,
members,
valueNodes,
pairs,
// Saved for one side only so far: nothing to switch between yet.
half: [...sides].some((id) => !(entry.on?.[id] && entry.off?.[id])),
gone,
...partState(members, counts, pairs),
};
});
}
// ---------- which groups, in which order ----------
export function matchTerms(text) {
return String(text ?? "")
.split(",")
.map((term) => term.trim().toLowerCase())
.filter(Boolean);
}
// "numbered" keeps the titles that start with a digit - the stage groups of
// a workflow laid out as 1 · Load, 2 · Create, ... - and "matching" keeps
// titles containing any of the comma-separated terms (no terms, no filter).
// "none" lists no group at all: only the made switches show.
export function groupListed(title, settings) {
const text = String(title ?? "").trim();
if (settings?.groups === "none") return false;
if (settings?.groups === "numbered") return /^\d/.test(text);
if (settings?.groups === "matching") {
const terms = matchTerms(settings.match);
const lower = text.toLowerCase();
return !terms.length || terms.some((term) => lower.includes(term));
}
return true;
}
// Canvas order is the way a left-to-right workflow reads: column by column,
// and top to bottom inside a column. A group joins the column on its left
// when it starts before that column's narrowest centre line, so a stage
// stacked under another reads straight after it (1, 2 | 3, 4 | 5 ...), where
// a row-by-row order would jump across the canvas and back.
export function canvasOrder(items, rectOf = (item) => item.rect) {
const sorted = [...items].sort((a, b) => rectOf(a)[0] - rectOf(b)[0] || rectOf(a)[1] - rectOf(b)[1]);
const columns = [];
let column = null;
let centre = Infinity;
for (const item of sorted) {
const [x, , w] = rectOf(item);
if (column && x < centre) {
column.push(item);
centre = Math.min(centre, x + w * 0.5);
continue;
}
column = [item];
centre = x + w * 0.5;
columns.push(column);
}
return columns.flatMap((members) => members.sort((a, b) => rectOf(a)[1] - rectOf(b)[1] || rectOf(a)[0] - rectOf(b)[0]));
}
const TITLE_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
// Title order compares numbers as numbers, so "10 · Save" follows "9 · Upscale".
export function titleOrder(items, titleOf = (item) => item.title) {
const canvas = canvasOrder(items);
const place = new Map(canvas.map((item, index) => [item, index]));
return [...items].sort((a, b) => TITLE_COLLATOR.compare(titleOf(a), titleOf(b)) || place.get(a) - place.get(b));
}
// The rows the card shows: the made switches in the order they were made
// (or moved to), then the listed groups in the chosen order.
export function listedRows(switchRows, groupRows, settings) {
const listed = (groupRows ?? []).filter((row) => groupListed(row.title, settings));
const ordered = settings?.order === "title" ? titleOrder(listed) : canvasOrder(listed);
return [...(switchRows ?? []), ...ordered];
}
// ---------- switching ----------
// What a click on a row's pill asks for. The pill is one switch: a click
// anywhere on it flips it, on the "off" half as much as on the "on" half.
// Only while the row reads mixed (neither half lit) does the half that was
// clicked decide: `side` is true for the on half.
export function flipTo(state, side) {
if (state === "on") return false;
if (state === "off") return true;
return Boolean(side);
}
// The mode changes one click makes: every node of the row turns on (always)
// or off (bypass or mute), exactly what the frontend's own "Bypass Group
// Nodes" / "Set Group Nodes to Always" do. One at a time first turns every
// other listed row off and then this one on, so a node two rows share ends
// up on. Returns [node, mode] pairs for the nodes that change.
export function switchPlan(rows, target, on, settings) {
const off = offMode(settings);
const modes = new Map();
if (on && settings?.exclusive) {
for (const row of rows) {
if (row === target) continue;
for (const node of row.members) modes.set(node, off);
}
}
for (const node of target?.members ?? []) modes.set(node, on ? MODE_ALWAYS : off);
return [...modes].filter(([node, mode]) => node.mode !== mode);
}
// Every listed row on or off at once (the node's right-click menu).
export function switchAllPlan(rows, on, settings) {
const mode = on ? MODE_ALWAYS : offMode(settings);
const modes = new Map();
for (const row of rows) for (const node of row.members) modes.set(node, mode);
return [...modes].filter(([node, value]) => node.mode !== value);
}
// The settings one click changes, as [pair, side]: every setting of the row
// goes to its on or its off value, and one at a time first sends the other
// rows' settings to off. Only the settings not already there are returned.
export function valuePlan(rows, target, on, settings) {
const sides = new Map();
const key = (pair) => `${pair.node}|${pair.widget}|${pair.row ?? ""}`;
if (on && settings?.exclusive) {
for (const row of rows) {
if (row === target) continue;
for (const pair of row.pairs ?? []) sides.set(key(pair), [pair, "off"]);
}
}
for (const pair of target?.pairs ?? []) sides.set(key(pair), [pair, on ? "on" : "off"]);
return [...sides.values()].filter(([pair, side]) => pair.now !== side);
}
export function valueAllPlan(rows, on) {
const sides = new Map();
for (const row of rows) for (const pair of row.pairs ?? []) sides.set(`${pair.node}|${pair.widget}|${pair.row ?? ""}`, [pair, on ? "on" : "off"]);
return [...sides.values()].filter(([pair, side]) => pair.now !== side);
}
// ---------- making and changing switches ----------
function withSwitches(settings, switches) {
return { ...normalizeSettings(settings), switches };
}
function pickedIds(picked) {
return { nodes: cleanIds(picked?.nodes), groups: cleanIds(picked?.groups) };
}
// A name for a new switch, from what was picked: the one box (subgraph) in
// the selection, else the one group, else the first node. `picked` carries
// titles here, not ids: { nodes: [{ title, box }], groups: [{ title }] }.
// A name already on the card gets a number, so two rows never read the same.
export function suggestTitle(picked, taken = []) {
const nodes = picked?.nodes ?? [];
const groups = picked?.groups ?? [];
const boxes = nodes.filter((node) => node?.box);
let base = "";
if (boxes.length === 1) base = boxes[0].title;
else if (groups.length === 1) base = groups[0].title;
else if (nodes.length) base = nodes[0].title;
else if (groups.length) base = groups[0].title;
base = cleanTitle(base) || "Switch";
const used = new Set(taken.map((title) => cleanTitle(title).toLowerCase()));
if (!used.has(base.toLowerCase())) return base;
for (let number = 2; number < 1000; number += 1) {
const next = cleanTitle(`${base.slice(0, TITLE_MAX - 4)} ${number}`);
if (!used.has(next.toLowerCase())) return next;
}
return base;
}
// A new switch at the end of the list. Returns the new settings and the
// switch's id, or id null when there is no room for another.
export function addSwitch(settings, { title, nodes, groups } = {}) {
const current = normalizeSettings(settings);
if (current.switches.length >= SWITCHES_MAX) return { settings: current, id: null };
const id = current.switches.reduce((highest, entry) => Math.max(highest, entry.id), 0) + 1;
const entry = { id, title: cleanTitle(title) || `Switch ${id}`, ...pickedIds({ nodes, groups }), on: {}, off: {} };
return { settings: withSwitches(current, [...current.switches, entry]), id };
}
function changeSwitch(settings, id, change) {
const current = normalizeSettings(settings);
return withSwitches(current, current.switches.map((entry) => (entry.id === id ? { ...entry, ...change(entry) } : entry)));
}
// An empty name keeps the old one: a switch is never left without a label.
export function renameSwitch(settings, id, title) {
const next = cleanTitle(title);
return changeSwitch(settings, id, (entry) => ({ title: next || entry.title }));
}
export function removeSwitch(settings, id) {
const current = normalizeSettings(settings);
return withSwitches(current, current.switches.filter((entry) => entry.id !== id));
}
// One place up (-1) or down (+1) the list; the ends stay put.
export function moveSwitch(settings, id, step) {
const current = normalizeSettings(settings);
const from = current.switches.findIndex((entry) => entry.id === id);
const to = from + (step < 0 ? -1 : 1);
if (from < 0 || to < 0 || to >= current.switches.length) return current;
const switches = [...current.switches];
[switches[from], switches[to]] = [switches[to], switches[from]];
return withSwitches(current, switches);
}
// A node whose settings the switch already changes stays that way: it is
// not also turned off.
export function addMembers(settings, id, picked) {
const extra = pickedIds(picked);
return changeSwitch(settings, id, (entry) => ({
nodes: cleanIds([...entry.nodes, ...extra.nodes]).filter((member) => !(String(member) in entry.on) && !(String(member) in entry.off)),
groups: cleanIds([...entry.groups, ...extra.groups]),
}));
}
function withoutKeys(sides, drop) {
return Object.fromEntries(Object.entries(sides ?? {}).filter(([id]) => !drop.has(id)));
}
// Taking a node out also forgets the values the switch saved for it.
export function removeMembers(settings, id, picked) {
const drop = pickedIds(picked);
const nodes = new Set(drop.nodes.map(String));
const groups = new Set(drop.groups.map(String));
return changeSwitch(settings, id, (entry) => ({
nodes: entry.nodes.filter((member) => !nodes.has(String(member))),
groups: entry.groups.filter((member) => !groups.has(String(member))),
on: withoutKeys(entry.on, nodes),
off: withoutKeys(entry.off, nodes),
}));
}
// How many of the picked nodes and groups a switch already holds, to turn
// off or to change the settings of.
export function heldCount(entry, picked) {
const want = pickedIds(picked);
const nodes = new Set([...(entry?.nodes ?? []).map(String), ...Object.keys(entry?.on ?? {}), ...Object.keys(entry?.off ?? {})]);
const groups = new Set((entry?.groups ?? []).map(String));
return want.nodes.filter((id) => nodes.has(String(id))).length + want.groups.filter((id) => groups.has(String(id))).length;
}
// Save what the picked nodes hold right now as one side of a switch ("on"
// or "off"). `snapshots` is [{ id, values }], values from snapshotNode. From
// then on the switch changes those nodes' settings and no longer turns them
// off.
export function saveValues(settings, id, side, snapshots) {
if (side !== "on" && side !== "off") return normalizeSettings(settings);
const saved = {};
for (const item of snapshots ?? []) {
const [nodeId] = cleanIds([item?.id]);
if (nodeId === undefined) continue;
saved[String(nodeId)] = item.values;
}
const ids = new Set(Object.keys(saved));
return changeSwitch(settings, id, (entry) => ({
nodes: entry.nodes.filter((member) => !ids.has(String(member))),
[side]: cleanSnapshots({ ...entry[side], ...saved }),
}));
}
// ---------- change detection ----------
// Everything the card draws, as one string: when it matches the last one,
// the redraw that asked has nothing to update.
export function rowsSignature(rows, settings, keyOf = (row) => row.title) {
return JSON.stringify([
settings,
rows.map((row) => [
row.kind, keyOf(row), row.title, row.color, row.state, row.on, row.total, row.members?.length ?? 0, row.gone ?? 0,
row.pairs?.length ?? 0, row.half ?? false,
]),
]);
}