Add interactive graph editor connections: port-based edges, drag-connect, and detach

- Add visible typed ports on graph nodes (colored dots for each edge type)
- Route edges to specific port positions instead of node center
- Drag from output port to compatible input port to create/change connections
- Right-click edge context menu with Disconnect option
- Delete key detaches selected edge
- Mark nested edges (composite layers, zones) as non-editable with dotted style
- Add resolve_ref helper for empty-string sentinel to clear reference fields
- Apply resolve_ref across all storage stores for consistent detach support
- Add connection mapping module (graph-connections.js) with API field resolution
- Add i18n keys for connection operations (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:15:33 +03:00
parent ff24ec95e6
commit b370bb7d75
17 changed files with 661 additions and 60 deletions

View File

@@ -10,6 +10,7 @@ from wled_controller.storage.audio_source import (
MultichannelAudioSource,
)
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.storage.utils import resolve_ref
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -112,15 +113,18 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
if is_loopback is not None:
source.is_loopback = bool(is_loopback)
if audio_template_id is not None:
source.audio_template_id = audio_template_id
source.audio_template_id = resolve_ref(audio_template_id, source.audio_template_id)
elif isinstance(source, MonoAudioSource):
if audio_source_id is not None:
parent = self._items.get(audio_source_id)
if not parent:
raise ValueError(f"Parent audio source not found: {audio_source_id}")
if not isinstance(parent, MultichannelAudioSource):
raise ValueError("Mono sources must reference a multichannel source")
source.audio_source_id = audio_source_id
resolved = resolve_ref(audio_source_id, source.audio_source_id)
if resolved is not None:
# Validate parent exists and is multichannel
parent = self._items.get(resolved)
if not parent:
raise ValueError(f"Parent audio source not found: {resolved}")
if not isinstance(parent, MultichannelAudioSource):
raise ValueError("Mono sources must reference a multichannel source")
source.audio_source_id = resolved
if channel is not None:
source.channel = channel

View File

@@ -84,11 +84,11 @@ class AutomationStore(BaseJsonStore[Automation]):
if conditions is not None:
automation.conditions = conditions
if scene_preset_id != "__unset__":
automation.scene_preset_id = scene_preset_id
automation.scene_preset_id = None if scene_preset_id == "" else scene_preset_id
if deactivation_mode is not None:
automation.deactivation_mode = deactivation_mode
if deactivation_scene_preset_id != "__unset__":
automation.deactivation_scene_preset_id = deactivation_scene_preset_id
automation.deactivation_scene_preset_id = None if deactivation_scene_preset_id == "" else deactivation_scene_preset_id
if tags is not None:
automation.tags = tags

View File

@@ -6,6 +6,7 @@ from typing import List, Optional
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.storage.utils import resolve_ref
from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
@@ -390,14 +391,14 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
source.description = description
if clock_id is not None:
source.clock_id = clock_id if clock_id else None
source.clock_id = resolve_ref(clock_id, source.clock_id)
if tags is not None:
source.tags = tags
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
if picture_source_id is not None and isinstance(source, PictureColorStripSource):
source.picture_source_id = picture_source_id
source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id)
if fps is not None:
source.fps = fps
if brightness is not None:
@@ -447,7 +448,7 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
if visualization_mode is not None:
source.visualization_mode = visualization_mode
if audio_source_id is not None:
source.audio_source_id = audio_source_id
source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id)
if sensitivity is not None:
source.sensitivity = float(sensitivity)
if smoothing is not None:

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.utils import resolve_ref
@dataclass
@@ -105,7 +106,7 @@ class KeyColorsOutputTarget(OutputTarget):
"""Apply mutable field updates for KC targets."""
super().update_fields(name=name, description=description, tags=tags)
if picture_source_id is not None:
self.picture_source_id = picture_source_id
self.picture_source_id = resolve_ref(picture_source_id, self.picture_source_id)
if key_colors_settings is not None:
self.settings = key_colors_settings

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional, Set
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.storage.utils import resolve_ref
from wled_controller.storage.picture_source import (
PictureSource,
ProcessedPictureSource,
@@ -183,7 +184,8 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
stream = self.get(stream_id)
# If changing source_stream_id on a processed stream, check for cycles
if source_stream_id is not None and isinstance(stream, ProcessedPictureSource):
# (skip validation when clearing via empty string)
if source_stream_id is not None and source_stream_id != "" and isinstance(stream, ProcessedPictureSource):
if source_stream_id not in self._items:
raise ValueError(f"Source stream not found: {source_stream_id}")
if self._detect_cycle(source_stream_id, exclude_stream_id=stream_id):
@@ -201,14 +203,14 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
if display_index is not None:
stream.display_index = display_index
if capture_template_id is not None:
stream.capture_template_id = capture_template_id
stream.capture_template_id = resolve_ref(capture_template_id, stream.capture_template_id)
if target_fps is not None:
stream.target_fps = target_fps
elif isinstance(stream, ProcessedPictureSource):
if source_stream_id is not None:
stream.source_stream_id = source_stream_id
stream.source_stream_id = resolve_ref(source_stream_id, stream.source_stream_id)
if postprocessing_template_id is not None:
stream.postprocessing_template_id = postprocessing_template_id
stream.postprocessing_template_id = resolve_ref(postprocessing_template_id, stream.postprocessing_template_id)
elif isinstance(stream, StaticImagePictureSource):
if image_source is not None:
stream.image_source = image_source

View File

@@ -0,0 +1,23 @@
"""Shared utilities for storage layer."""
from typing import Optional
def resolve_ref(new_value: Optional[str], current_value: Optional[str]) -> Optional[str]:
"""Resolve a reference field update.
Handles three cases for nullable reference ID fields:
- new_value == '' -> clear to None (detach)
- new_value is None -> keep current value (no change)
- otherwise -> use new_value
Args:
new_value: The incoming value from the API update request.
current_value: The current value stored on the entity.
Returns:
The resolved value to assign to the field.
"""
if new_value == "":
return None
return new_value if new_value is not None else current_value

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.storage.utils import resolve_ref
from wled_controller.storage.value_source import (
AdaptiveValueSource,
AnimatedValueSource,
@@ -179,7 +180,7 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
source.max_value = max_value
elif isinstance(source, AudioValueSource):
if audio_source_id is not None:
source.audio_source_id = audio_source_id
source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id)
if mode is not None:
source.mode = mode
if sensitivity is not None:
@@ -198,7 +199,7 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
raise ValueError("Time of day schedule requires at least 2 points")
source.schedule = schedule
if picture_source_id is not None:
source.picture_source_id = picture_source_id
source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id)
if scene_behavior is not None:
source.scene_behavior = scene_behavior
if sensitivity is not None:

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.utils import resolve_ref
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
@@ -68,11 +69,11 @@ class WledOutputTarget(OutputTarget):
"""Apply mutable field updates for WLED targets."""
super().update_fields(name=name, description=description, tags=tags)
if device_id is not None:
self.device_id = device_id
self.device_id = resolve_ref(device_id, self.device_id)
if color_strip_source_id is not None:
self.color_strip_source_id = color_strip_source_id
self.color_strip_source_id = resolve_ref(color_strip_source_id, self.color_strip_source_id)
if brightness_value_source_id is not None:
self.brightness_value_source_id = brightness_value_source_id
self.brightness_value_source_id = resolve_ref(brightness_value_source_id, self.brightness_value_source_id)
if fps is not None:
self.fps = fps
if keepalive_interval is not None: