Unify graph docking, fix device hot-switch, and compact UI cards
- Unify minimap/toolbar/legend drag+dock into shared _makeDraggable() helper - Persist legend visibility and position, add active state to toggle buttons - Show custom colors only on graph cards (entity defaults remain in legend) - Replace emoji overlay buttons with SVG path icons - Fix stale is_running blocking target start (auto-clear if task is done) - Resolve device/target IDs to names in conflict error messages - Hot-switch LED device on running target via async stop-swap-start cycle - Compact automation dashboard cards and fix time_of_day localization - Inline CSS source pill on target cards to save vertical space Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -315,12 +315,18 @@ async def update_target(
|
||||
data.adaptive_fps is not None or
|
||||
data.key_colors_settings is not None),
|
||||
css_changed=data.color_strip_source_id is not None,
|
||||
device_changed=data.device_id is not None,
|
||||
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Device change requires async stop → swap → start cycle
|
||||
if data.device_id is not None:
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
fire_entity_event("output_target", "updated", target_id)
|
||||
return _target_to_response(target)
|
||||
|
||||
@@ -389,7 +395,12 @@ async def start_processing(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
# Resolve target IDs to human-readable names in error messages
|
||||
msg = str(e)
|
||||
for t in target_store.get_all_targets():
|
||||
if t.id in msg:
|
||||
msg = msg.replace(t.id, f"'{t.name}'")
|
||||
raise HTTPException(status_code=409, detail=msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -465,13 +465,33 @@ class ProcessorManager:
|
||||
proc = self._get_processor(target_id)
|
||||
proc.update_css_source(color_strip_source_id)
|
||||
|
||||
def update_target_device(self, target_id: str, device_id: str):
|
||||
"""Update the device for a target."""
|
||||
async def update_target_device(self, target_id: str, device_id: str):
|
||||
"""Update the device for a target.
|
||||
|
||||
If the target is currently running, performs a stop → swap → start
|
||||
cycle so the new device connection is established properly.
|
||||
"""
|
||||
proc = self._get_processor(target_id)
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not registered")
|
||||
|
||||
was_running = proc.is_running
|
||||
if was_running:
|
||||
logger.info(
|
||||
"Hot-switching device for running target %s: stopping first",
|
||||
target_id,
|
||||
)
|
||||
await self.stop_processing(target_id)
|
||||
|
||||
proc.update_device(device_id)
|
||||
|
||||
if was_running:
|
||||
await self.start_processing(target_id)
|
||||
logger.info(
|
||||
"Hot-switch complete for target %s → device %s",
|
||||
target_id, device_id,
|
||||
)
|
||||
|
||||
def update_target_brightness_vs(self, target_id: str, vs_id: str):
|
||||
"""Update the brightness value source for a WLED target."""
|
||||
proc = self._get_processor(target_id)
|
||||
@@ -495,8 +515,25 @@ class ProcessorManager:
|
||||
and other.device_id == proc.device_id
|
||||
and other.is_running
|
||||
):
|
||||
# Stale state guard: if the task is actually finished,
|
||||
# clean up and allow starting instead of blocking.
|
||||
task = getattr(other, "_task", None)
|
||||
if task is not None and task.done():
|
||||
logger.warning(
|
||||
"Processor %s had stale is_running=True (task done) — clearing",
|
||||
other_id,
|
||||
)
|
||||
other._is_running = False
|
||||
continue
|
||||
|
||||
dev_name = proc.device_id
|
||||
tgt_name = other_id
|
||||
if self._device_store:
|
||||
dev = self._device_store.get_device(proc.device_id)
|
||||
if dev:
|
||||
dev_name = dev.name
|
||||
raise RuntimeError(
|
||||
f"Device {proc.device_id} is already being processed by target {other_id}"
|
||||
f"Device '{dev_name}' is already being processed by target {tgt_name}"
|
||||
)
|
||||
|
||||
# Close cached idle client — processor creates its own connection
|
||||
|
||||
@@ -83,27 +83,44 @@
|
||||
.graph-legend {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 20;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
font-size: 0.8rem;
|
||||
display: none;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-legend.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.graph-legend-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.graph-legend-header.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-legend-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.graph-legend-body {
|
||||
padding: 6px 12px;
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.graph-legend-item {
|
||||
@@ -112,6 +129,7 @@
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.graph-legend-dot {
|
||||
|
||||
@@ -91,14 +91,20 @@ function _saveNodeColor(nodeId, color) {
|
||||
localStorage.setItem(_NC_KEY, JSON.stringify(map));
|
||||
}
|
||||
|
||||
export function getNodeColor(nodeId, kind) {
|
||||
/** Return custom color for a node, or null if none set. */
|
||||
export function getNodeColor(nodeId) {
|
||||
const map = _loadNodeColors();
|
||||
return map[nodeId] || ENTITY_COLORS[kind] || '#666';
|
||||
return map[nodeId] || null;
|
||||
}
|
||||
|
||||
/** Return color for a node: custom if set, else entity-type default. Used by minimap/search. */
|
||||
export function getNodeDisplayColor(nodeId, kind) {
|
||||
return getNodeColor(nodeId) || ENTITY_COLORS[kind] || '#666';
|
||||
}
|
||||
|
||||
function renderNode(node, callbacks) {
|
||||
const { id, kind, name, subtype, x, y, width, height, running } = node;
|
||||
const color = getNodeColor(id, kind);
|
||||
let color = getNodeColor(id);
|
||||
|
||||
const g = svgEl('g', {
|
||||
class: `graph-node${running ? ' running' : ''}`,
|
||||
@@ -116,21 +122,23 @@ function renderNode(node, callbacks) {
|
||||
});
|
||||
g.appendChild(body);
|
||||
|
||||
// Color bar (left strip)
|
||||
// Color bar (left strip) — only visible when user has set a custom color
|
||||
const bar = svgEl('rect', {
|
||||
class: 'graph-node-color-bar',
|
||||
x: 0, y: 0,
|
||||
width: 6, height,
|
||||
rx: 8, ry: 8,
|
||||
fill: color,
|
||||
fill: color || 'transparent',
|
||||
});
|
||||
if (!color) bar.style.display = 'none';
|
||||
g.appendChild(bar);
|
||||
// Cover the right side rounded corners of the bar
|
||||
const barCover = svgEl('rect', {
|
||||
x: 3, y: 0,
|
||||
width: 4, height,
|
||||
fill: color,
|
||||
fill: color || 'transparent',
|
||||
});
|
||||
if (!color) barCover.style.display = 'none';
|
||||
g.appendChild(barCover);
|
||||
|
||||
// Clickable color bar overlay (wider hit area)
|
||||
@@ -165,7 +173,7 @@ function renderNode(node, callbacks) {
|
||||
overlay.style.cssText = `position:absolute; left:${px}px; top:${py}px; z-index:100;`;
|
||||
overlay.innerHTML = createColorPicker({
|
||||
id: pickerId,
|
||||
currentColor: color,
|
||||
currentColor: color || ENTITY_COLORS[kind] || '#666',
|
||||
anchor: 'left',
|
||||
});
|
||||
container.appendChild(overlay);
|
||||
@@ -175,6 +183,8 @@ function renderNode(node, callbacks) {
|
||||
color = hex;
|
||||
bar.setAttribute('fill', hex);
|
||||
barCover.setAttribute('fill', hex);
|
||||
bar.style.display = '';
|
||||
barCover.style.display = '';
|
||||
_saveNodeColor(id, hex);
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ function createAutomationCard(automation, sceneMap = new Map()) {
|
||||
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
|
||||
}
|
||||
if (c.condition_type === 'time_of_day') {
|
||||
return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`;
|
||||
return `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`;
|
||||
}
|
||||
if (c.condition_type === 'system_idle') {
|
||||
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
|
||||
@@ -207,12 +207,9 @@ function createAutomationCard(automation, sceneMap = new Map()) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-subtitle">
|
||||
<span class="card-meta">${automation.condition_logic === 'and' ? t('automations.logic.all') : t('automations.logic.any')}</span>
|
||||
<span class="card-meta">${condPills}</span>
|
||||
<span class="card-meta">${ICON_SCENE} <span style="color:${sceneColor}">●</span> ${sceneName}</span>
|
||||
${deactivationLabel ? `<span class="card-meta">${deactivationLabel}</span>` : ''}
|
||||
${lastActivityMeta}
|
||||
</div>
|
||||
<div class="stream-card-props">${condPills}</div>
|
||||
${renderTagChips(automation.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { GraphCanvas } from '../core/graph-canvas.js';
|
||||
import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
|
||||
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeColor } from '../core/graph-nodes.js';
|
||||
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeDisplayColor } from '../core/graph-nodes.js';
|
||||
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
|
||||
import {
|
||||
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
||||
@@ -24,7 +24,7 @@ let _edges = null;
|
||||
let _bounds = null;
|
||||
let _selectedIds = new Set();
|
||||
let _initialized = false;
|
||||
let _legendVisible = false;
|
||||
let _legendVisible = (() => { try { return localStorage.getItem('graph_legend_visible') === '1'; } catch { return false; } })();
|
||||
let _minimapVisible = true;
|
||||
let _searchVisible = false;
|
||||
let _searchIndex = -1;
|
||||
@@ -103,16 +103,6 @@ function _applyAnchor(el, container, saved) {
|
||||
/** True when the graph container is in fullscreen — suppress anchor persistence. */
|
||||
function _isFullscreen() { return !!document.fullscreenElement; }
|
||||
|
||||
/** Shorthand wrappers for minimap / toolbar (skip save in fullscreen). */
|
||||
function _saveMinimapAnchored(el, container) {
|
||||
if (_isFullscreen()) return;
|
||||
return _saveAnchored(el, container, _saveMinimapRect);
|
||||
}
|
||||
function _saveToolbarAnchored(el, container) {
|
||||
if (_isFullscreen()) return;
|
||||
return _saveAnchored(el, container, _saveToolbarPos);
|
||||
}
|
||||
|
||||
// Toolbar position persisted in localStorage
|
||||
const _TB_KEY = 'graph_toolbar';
|
||||
function _loadToolbarPos() {
|
||||
@@ -122,6 +112,63 @@ function _saveToolbarPos(r) {
|
||||
localStorage.setItem(_TB_KEY, JSON.stringify(r));
|
||||
}
|
||||
|
||||
// Legend position persisted in localStorage
|
||||
const _LG_KEY = 'graph_legend';
|
||||
function _loadLegendPos() {
|
||||
try { return JSON.parse(localStorage.getItem(_LG_KEY)); } catch { return null; }
|
||||
}
|
||||
function _saveLegendPos(r) {
|
||||
localStorage.setItem(_LG_KEY, JSON.stringify(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic draggable panel setup.
|
||||
* @param {HTMLElement} el - The panel element
|
||||
* @param {HTMLElement} handle - The drag handle element
|
||||
* @param {object} opts - { loadFn, saveFn }
|
||||
*/
|
||||
function _makeDraggable(el, handle, { loadFn, saveFn }) {
|
||||
if (!el || !handle) return;
|
||||
const container = el.closest('.graph-container');
|
||||
if (!container) return;
|
||||
|
||||
// Apply saved anchor position or clamp
|
||||
const saved = loadFn();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(el, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(el, container);
|
||||
}
|
||||
|
||||
let dragStart = null, dragStartPos = null;
|
||||
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
dragStart = { x: e.clientX, y: e.clientY };
|
||||
dragStartPos = { left: el.offsetLeft, top: el.offsetTop };
|
||||
handle.classList.add('dragging');
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!dragStart) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const ew = el.offsetWidth, eh = el.offsetHeight;
|
||||
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
||||
let t = dragStartPos.top + (e.clientY - dragStart.y);
|
||||
l = Math.max(0, Math.min(cr.width - ew, l));
|
||||
t = Math.max(0, Math.min(cr.height - eh, t));
|
||||
el.style.left = l + 'px';
|
||||
el.style.top = t + 'px';
|
||||
});
|
||||
handle.addEventListener('pointerup', () => {
|
||||
if (dragStart) {
|
||||
dragStart = null;
|
||||
handle.classList.remove('dragging');
|
||||
if (!_isFullscreen()) _saveAnchored(el, container, saveFn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export async function loadGraphEditor() {
|
||||
@@ -183,14 +230,34 @@ export function closeGraphSearch() {
|
||||
|
||||
export function toggleGraphLegend() {
|
||||
_legendVisible = !_legendVisible;
|
||||
try { localStorage.setItem('graph_legend_visible', _legendVisible ? '1' : '0'); } catch {}
|
||||
const legend = document.querySelector('.graph-legend');
|
||||
if (legend) legend.classList.toggle('visible', _legendVisible);
|
||||
if (!legend) return;
|
||||
legend.classList.toggle('visible', _legendVisible);
|
||||
const legendBtn = document.getElementById('graph-legend-toggle');
|
||||
if (legendBtn) legendBtn.classList.toggle('active', _legendVisible);
|
||||
if (_legendVisible) {
|
||||
const container = legend.closest('.graph-container');
|
||||
if (container) {
|
||||
const saved = _loadLegendPos();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(legend, container, saved);
|
||||
} else if (!legend.style.left) {
|
||||
// Default to top-right
|
||||
const cr = container.getBoundingClientRect();
|
||||
legend.style.left = (cr.width - legend.offsetWidth - 12) + 'px';
|
||||
legend.style.top = '12px';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleGraphMinimap() {
|
||||
_minimapVisible = !_minimapVisible;
|
||||
const mm = document.querySelector('.graph-minimap');
|
||||
if (mm) mm.classList.toggle('visible', _minimapVisible);
|
||||
const mmBtn = document.getElementById('graph-minimap-toggle');
|
||||
if (mmBtn) mmBtn.classList.toggle('active', _minimapVisible);
|
||||
}
|
||||
|
||||
export function toggleGraphFilter() {
|
||||
@@ -507,7 +574,9 @@ function _renderGraph(container) {
|
||||
_updateMinimapViewport(container.querySelector('.graph-minimap'), vp);
|
||||
};
|
||||
|
||||
_renderLegend(container.querySelector('.graph-legend'));
|
||||
const legendEl = container.querySelector('.graph-legend');
|
||||
_renderLegend(legendEl);
|
||||
_initLegendDrag(legendEl);
|
||||
_initMinimap(container.querySelector('.graph-minimap'));
|
||||
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
||||
_initResizeClamp(container);
|
||||
@@ -627,10 +696,10 @@ function _graphHTML() {
|
||||
<button class="btn-icon graph-filter-btn" onclick="toggleGraphFilter()" title="${t('graph.filter')} (F)">
|
||||
<svg class="icon" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="toggleGraphLegend()" title="${t('graph.legend')}">
|
||||
<button class="btn-icon${_legendVisible ? ' active' : ''}" id="graph-legend-toggle" onclick="toggleGraphLegend()" title="${t('graph.legend')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="toggleGraphMinimap()" title="${t('graph.minimap')}">
|
||||
<button class="btn-icon${_minimapVisible ? ' active' : ''}" id="graph-minimap-toggle" onclick="toggleGraphMinimap()" title="${t('graph.minimap')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
|
||||
</button>
|
||||
<span class="graph-toolbar-sep"></span>
|
||||
@@ -646,8 +715,11 @@ function _graphHTML() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="graph-legend">
|
||||
<div class="graph-legend-title">${t('graph.legend')}</div>
|
||||
<div class="graph-legend${_legendVisible ? ' visible' : ''}">
|
||||
<div class="graph-legend-header">
|
||||
<span class="graph-legend-title">${t('graph.legend')}</span>
|
||||
</div>
|
||||
<div class="graph-legend-body"></div>
|
||||
</div>
|
||||
|
||||
<div class="graph-minimap${_minimapVisible ? ' visible' : ''}" style="${mmStyle}">
|
||||
@@ -691,7 +763,9 @@ function _graphHTML() {
|
||||
|
||||
function _renderLegend(legendEl) {
|
||||
if (!legendEl) return;
|
||||
let html = `<div class="graph-legend-title">${t('graph.legend')}</div>`;
|
||||
const body = legendEl.querySelector('.graph-legend-body');
|
||||
if (!body) return;
|
||||
let html = '';
|
||||
for (const [kind, color] of Object.entries(ENTITY_COLORS)) {
|
||||
const label = ENTITY_LABELS[kind] || kind;
|
||||
html += `<div class="graph-legend-item">
|
||||
@@ -699,7 +773,13 @@ function _renderLegend(legendEl) {
|
||||
<span>${label}</span>
|
||||
</div>`;
|
||||
}
|
||||
legendEl.innerHTML = html;
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function _initLegendDrag(legendEl) {
|
||||
if (!legendEl) return;
|
||||
const handle = legendEl.querySelector('.graph-legend-header');
|
||||
_makeDraggable(legendEl, handle, { loadFn: _loadLegendPos, saveFn: _saveLegendPos });
|
||||
}
|
||||
|
||||
/* ── Minimap (draggable header & resize handle) ── */
|
||||
@@ -716,7 +796,7 @@ function _initMinimap(mmEl) {
|
||||
|
||||
let html = '';
|
||||
for (const node of _nodeMap.values()) {
|
||||
const color = getNodeColor(node.id, node.kind);
|
||||
const color = getNodeDisplayColor(node.id, node.kind);
|
||||
html += `<rect class="graph-minimap-node" data-id="${node.id}" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
|
||||
}
|
||||
// Add viewport rect (updated live via _updateMinimapViewport)
|
||||
@@ -763,31 +843,9 @@ function _initMinimap(mmEl) {
|
||||
});
|
||||
svg.addEventListener('pointerup', () => { mmDraggingViewport = false; });
|
||||
|
||||
// ── Drag via header ──
|
||||
// ── Drag via header (uses shared _makeDraggable) ──
|
||||
const header = mmEl.querySelector('.graph-minimap-header');
|
||||
let dragStart = null, dragStartPos = null;
|
||||
|
||||
header.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
dragStart = { x: e.clientX, y: e.clientY };
|
||||
dragStartPos = { left: mmEl.offsetLeft, top: mmEl.offsetTop };
|
||||
header.classList.add('dragging');
|
||||
header.setPointerCapture(e.pointerId);
|
||||
});
|
||||
header.addEventListener('pointermove', (e) => {
|
||||
if (!dragStart) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight;
|
||||
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
||||
let t = dragStartPos.top + (e.clientY - dragStart.y);
|
||||
l = Math.max(0, Math.min(cr.width - mw, l));
|
||||
t = Math.max(0, Math.min(cr.height - mh, t));
|
||||
mmEl.style.left = l + 'px';
|
||||
mmEl.style.top = t + 'px';
|
||||
});
|
||||
header.addEventListener('pointerup', () => {
|
||||
if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapAnchored(mmEl, container); }
|
||||
});
|
||||
_makeDraggable(mmEl, header, { loadFn: () => null, saveFn: _saveMinimapRect });
|
||||
|
||||
// ── Resize handles ──
|
||||
_initResizeHandle(mmEl.querySelector('.graph-minimap-resize-br'), 'br');
|
||||
@@ -824,7 +882,7 @@ function _initMinimap(mmEl) {
|
||||
}
|
||||
_clampMinimap();
|
||||
});
|
||||
rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapAnchored(mmEl, container); } });
|
||||
rh.addEventListener('pointerup', () => { if (rs) { rs = null; if (!_isFullscreen()) _saveAnchored(mmEl, container, _saveMinimapRect); } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,37 +931,26 @@ function _clampElementInContainer(el, container) {
|
||||
|
||||
let _resizeObserver = null;
|
||||
|
||||
function _reanchorPanel(el, container, loadFn) {
|
||||
if (!el) return;
|
||||
if (_isFullscreen()) {
|
||||
_clampElementInContainer(el, container);
|
||||
} else {
|
||||
const saved = loadFn();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(el, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(el, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _initResizeClamp(container) {
|
||||
if (_resizeObserver) _resizeObserver.disconnect();
|
||||
_resizeObserver = new ResizeObserver(() => {
|
||||
// In fullscreen, just clamp — don't re-anchor from normal-mode saved data
|
||||
const fs = _isFullscreen();
|
||||
const mm = container.querySelector('.graph-minimap');
|
||||
const tb = container.querySelector('.graph-toolbar');
|
||||
if (mm) {
|
||||
if (fs) {
|
||||
_clampElementInContainer(mm, container);
|
||||
} else {
|
||||
const saved = _loadMinimapRect();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(mm, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(mm, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tb) {
|
||||
if (fs) {
|
||||
_clampElementInContainer(tb, container);
|
||||
} else {
|
||||
const saved = _loadToolbarPos();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(tb, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(tb, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
_reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect);
|
||||
_reanchorPanel(container.querySelector('.graph-toolbar'), container, _loadToolbarPos);
|
||||
_reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos);
|
||||
});
|
||||
_resizeObserver.observe(container);
|
||||
}
|
||||
@@ -912,44 +959,8 @@ function _initResizeClamp(container) {
|
||||
|
||||
function _initToolbarDrag(tbEl) {
|
||||
if (!tbEl) return;
|
||||
const container = tbEl.closest('.graph-container');
|
||||
|
||||
// Apply saved anchor position or clamp
|
||||
const saved = _loadToolbarPos();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(tbEl, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(tbEl, container);
|
||||
}
|
||||
|
||||
const handle = tbEl.querySelector('.graph-toolbar-drag');
|
||||
if (!handle) return;
|
||||
|
||||
let dragStart = null, dragStartPos = null;
|
||||
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
dragStart = { x: e.clientX, y: e.clientY };
|
||||
dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop };
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!dragStart) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const tw = tbEl.offsetWidth, th = tbEl.offsetHeight;
|
||||
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
||||
let t = dragStartPos.top + (e.clientY - dragStart.y);
|
||||
l = Math.max(0, Math.min(cr.width - tw, l));
|
||||
t = Math.max(0, Math.min(cr.height - th, t));
|
||||
tbEl.style.left = l + 'px';
|
||||
tbEl.style.top = t + 'px';
|
||||
});
|
||||
handle.addEventListener('pointerup', () => {
|
||||
if (dragStart) {
|
||||
dragStart = null;
|
||||
_saveToolbarAnchored(tbEl, container);
|
||||
}
|
||||
});
|
||||
_makeDraggable(tbEl, handle, { loadFn: _loadToolbarPos, saveFn: _saveToolbarPos });
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
|
||||
@@ -1011,7 +1011,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
|
||||
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
|
||||
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
|
||||
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
|
||||
<span class="stream-card-prop stream-card-prop-full${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
|
||||
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${target.min_brightness_threshold} → off</span>` : ''}
|
||||
</div>
|
||||
|
||||
@@ -42,9 +42,12 @@ class WledOutputTarget(OutputTarget):
|
||||
|
||||
def sync_with_manager(self, manager, *, settings_changed: bool,
|
||||
css_changed: bool = False,
|
||||
device_changed: bool = False,
|
||||
brightness_vs_changed: bool = False) -> None:
|
||||
"""Push changed fields to the processor manager."""
|
||||
"""Push changed fields to the processor manager.
|
||||
|
||||
NOTE: device_changed is handled separately in the route because
|
||||
update_target_device is async (stop → swap → start cycle).
|
||||
"""
|
||||
if settings_changed:
|
||||
manager.update_target_settings(self.id, {
|
||||
"fps": self.fps,
|
||||
@@ -55,8 +58,6 @@ class WledOutputTarget(OutputTarget):
|
||||
})
|
||||
if css_changed:
|
||||
manager.update_target_css(self.id, self.color_strip_source_id)
|
||||
if device_changed:
|
||||
manager.update_target_device(self.id, self.device_id)
|
||||
if brightness_vs_changed:
|
||||
manager.update_target_brightness_vs(self.id, self.brightness_value_source_id)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user