Add dashboard crosslinks and card drag-and-drop reordering

Dashboard cards (targets, auto-start, profiles) are now clickable,
navigating to the full entity card on the appropriate tab. Card
sections support drag-and-drop reordering via grip handles with
localStorage persistence. Fix crosslink navigation scoping to avoid
matching dashboard cards, and fix highlight race on rapid clicks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 00:40:37 +03:00
parent 88abd31c1c
commit 9194b978e0
8 changed files with 364 additions and 38 deletions

View File

@@ -24,6 +24,10 @@
import { t } from './i18n.js';
const STORAGE_KEY = 'sections_collapsed';
const ORDER_PREFIX = 'card_order_';
const DRAG_THRESHOLD = 5;
const SCROLL_EDGE = 60;
const SCROLL_SPEED = 12;
function _getCollapsedMap() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
@@ -48,6 +52,8 @@ export class CardSection {
this.keyAttr = keyAttr || '';
this._filterValue = '';
this._lastItems = null;
this._dragState = null;
this._dragBound = false;
}
/** True if this section's DOM element exists (i.e. not the first render). */
@@ -61,6 +67,7 @@ export class CardSection {
*/
render(items) {
this._lastItems = items;
this._dragBound = false; // DOM will be recreated → need to re-init drag
const count = items.length;
const cardsHtml = items.map(i => i.html).join('');
@@ -155,6 +162,12 @@ export class CardSection {
// Tag card elements with their source HTML for future reconciliation
this._tagCards(content);
// Inject drag handles and initialize drag-and-drop
if (this.keyAttr) {
this._injectDragHandles(content);
this._initDrag(content);
}
// Stagger card entrance animation
this._animateEntrance(content);
}
@@ -232,6 +245,11 @@ export class CardSection {
}
}
// Re-inject drag handles on new/replaced cards
if (this.keyAttr && (added.size > 0 || replaced.size > 0)) {
this._injectDragHandles(content);
}
// Re-apply filter
if (this._filterValue) {
this._applyFilter(content, this._filterValue);
@@ -292,6 +310,34 @@ export class CardSection {
}
}
/**
* Reorder items array according to saved drag order.
* Call before render() / reconcile().
*/
applySortOrder(items) {
if (!this.keyAttr) return items;
const order = this._getSavedOrder();
if (!order.length) return items;
const orderMap = new Map(order.map((key, idx) => [key, idx]));
const sorted = [...items];
sorted.sort((a, b) => {
const ia = orderMap.has(a.key) ? orderMap.get(a.key) : Infinity;
const ib = orderMap.has(b.key) ? orderMap.get(b.key) : Infinity;
if (ia !== ib) return ia - ib;
return 0; // preserve original order for unranked items
});
return sorted;
}
_getSavedOrder() {
try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey)) || []; }
catch { return []; }
}
_saveOrder(keys) {
localStorage.setItem(ORDER_PREFIX + this.sectionKey, JSON.stringify(keys));
}
// ── private ──
_animateEntrance(content) {
@@ -368,11 +414,13 @@ export class CardSection {
const total = cards.length;
if (!query) {
content.classList.remove('cs-filtering');
cards.forEach(card => { card.style.display = ''; });
if (addCard) addCard.style.display = '';
if (countEl) countEl.textContent = total;
return;
}
content.classList.add('cs-filtering');
const lower = query.toLowerCase();
// Comma-separated segments → AND; spaces within a segment → OR
@@ -390,4 +438,166 @@ export class CardSection {
if (addCard) addCard.style.display = 'none';
if (countEl) countEl.textContent = `${visible}/${total}`;
}
// ── drag-and-drop reordering ──
_injectDragHandles(content) {
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
cards.forEach(card => {
if (card.querySelector('.card-drag-handle')) return;
const handle = document.createElement('span');
handle.className = 'card-drag-handle';
handle.textContent = '\u2807'; // ⠇ braille dots vertical
handle.setAttribute('aria-hidden', 'true');
card.prepend(handle);
});
}
_initDrag(content) {
if (this._dragBound) return;
this._dragBound = true;
content.addEventListener('pointerdown', (e) => {
if (this._filterValue) return;
const handle = e.target.closest('.card-drag-handle');
if (!handle) return;
const card = handle.closest(`[${this.keyAttr}]`);
if (!card) return;
e.preventDefault();
this._dragState = {
card,
content,
startX: e.clientX,
startY: e.clientY,
started: false,
clone: null,
placeholder: null,
scrollRaf: null,
};
const onMove = (ev) => this._onDragMove(ev);
const onUp = (ev) => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
this._onDragEnd(ev);
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
});
}
_onDragMove(e) {
const ds = this._dragState;
if (!ds) return;
if (!ds.started) {
const dx = e.clientX - ds.startX;
const dy = e.clientY - ds.startY;
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
this._startDrag(ds, e);
}
// Position clone at pointer
ds.clone.style.left = (e.clientX - ds.offsetX) + 'px';
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
// Find drop target
const target = this._getDropTarget(e.clientX, e.clientY, ds.content);
if (target && target !== ds.placeholder) {
const rect = target.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
const midY = rect.top + rect.height / 2;
// For grid: use combined horizontal+vertical check
const insertBefore = (e.clientY < midY) || (e.clientY < midY + rect.height * 0.3 && e.clientX < midX);
if (insertBefore) {
ds.content.insertBefore(ds.placeholder, target);
} else {
ds.content.insertBefore(ds.placeholder, target.nextSibling);
}
}
// Auto-scroll near viewport edges
this._autoScroll(e.clientY, ds);
}
_startDrag(ds, e) {
ds.started = true;
const rect = ds.card.getBoundingClientRect();
// Clone for visual drag
const clone = ds.card.cloneNode(true);
clone.className = ds.card.className + ' card-drag-clone';
clone.style.width = rect.width + 'px';
clone.style.height = rect.height + 'px';
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
document.body.appendChild(clone);
ds.clone = clone;
ds.offsetX = e.clientX - rect.left;
ds.offsetY = e.clientY - rect.top;
// Placeholder
const placeholder = document.createElement('div');
placeholder.className = 'card-drag-placeholder';
placeholder.style.width = rect.width + 'px';
placeholder.style.height = rect.height + 'px';
ds.card.parentNode.insertBefore(placeholder, ds.card);
ds.placeholder = placeholder;
// Hide original
ds.card.style.display = 'none';
ds.content.classList.add('cs-dragging');
}
_onDragEnd() {
const ds = this._dragState;
this._dragState = null;
if (!ds || !ds.started) return;
// Cancel auto-scroll
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
// Move original card to placeholder position
ds.content.insertBefore(ds.card, ds.placeholder);
ds.card.style.display = '';
ds.placeholder.remove();
ds.clone.remove();
ds.content.classList.remove('cs-dragging');
// Save new order from DOM
const keys = this._readDomOrder(ds.content);
this._saveOrder(keys);
}
_getDropTarget(x, y, content) {
// Temporarily show all cards for hit testing
const els = document.elementsFromPoint(x, y);
for (const el of els) {
if (el === content) break;
const card = el.closest(`[${this.keyAttr}]`);
if (card && card.style.display !== 'none' && content.contains(card)) return card;
}
return null;
}
_readDomOrder(content) {
return [...content.querySelectorAll(`[${this.keyAttr}]`)]
.map(el => el.getAttribute(this.keyAttr));
}
_autoScroll(clientY, ds) {
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
const vp = window.innerHeight;
let speed = 0;
if (clientY < SCROLL_EDGE) speed = -SCROLL_SPEED * (1 - clientY / SCROLL_EDGE);
else if (clientY > vp - SCROLL_EDGE) speed = SCROLL_SPEED * (1 - (vp - clientY) / SCROLL_EDGE);
if (Math.abs(speed) < 0.5) return;
const scroll = () => {
window.scrollBy(0, speed);
ds.scrollRaf = requestAnimationFrame(scroll);
};
ds.scrollRaf = requestAnimationFrame(scroll);
}
}

View File

@@ -45,8 +45,13 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
}
}
// Scope card search to the destination tab panel (avoid matching
// dashboard cards that share the same data-* attributes).
const tabPanel = document.getElementById(`tab-${tab}`);
const scope = tabPanel || document;
// Check if card already exists (data previously loaded)
const existing = document.querySelector(`[${cardAttr}="${cardValue}"]`);
const existing = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
if (existing) {
_highlightCard(existing);
return;
@@ -54,17 +59,29 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
// Card not in DOM — trigger data load and wait for it to appear
_triggerTabLoad(tab);
_waitForCard(cardAttr, cardValue, 5000).then(card => {
_waitForCard(cardAttr, cardValue, 5000, scope).then(card => {
if (card) _highlightCard(card);
});
});
}
let _highlightTimer = 0;
let _overlayTimer = 0;
let _prevCard = null;
function _highlightCard(card) {
// Clear previous highlight if still active
if (_prevCard) _prevCard.classList.remove('card-highlight');
clearTimeout(_highlightTimer);
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('card-highlight');
_prevCard = card;
_showDimOverlay(2000);
setTimeout(() => card.classList.remove('card-highlight'), 2000);
_highlightTimer = setTimeout(() => {
card.classList.remove('card-highlight');
_prevCard = null;
}, 2000);
}
/** Trigger the tab's data load function (used when card wasn't found in DOM). */
@@ -76,6 +93,7 @@ function _triggerTabLoad(tab) {
}
function _showDimOverlay(duration) {
clearTimeout(_overlayTimer);
let overlay = document.getElementById('nav-dim-overlay');
if (!overlay) {
overlay = document.createElement('div');
@@ -84,19 +102,20 @@ function _showDimOverlay(duration) {
document.body.appendChild(overlay);
}
overlay.classList.add('active');
setTimeout(() => overlay.classList.remove('active'), duration);
_overlayTimer = setTimeout(() => overlay.classList.remove('active'), duration);
}
function _waitForCard(cardAttr, cardValue, timeout) {
function _waitForCard(cardAttr, cardValue, timeout, scope = document) {
const root = scope === document ? document.body : scope;
return new Promise(resolve => {
const card = document.querySelector(`[${cardAttr}="${cardValue}"]`);
const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
if (card) { resolve(card); return; }
const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
const observer = new MutationObserver(() => {
const el = document.querySelector(`[${cardAttr}="${cardValue}"]`);
const el = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); }
});
observer.observe(document.body, { childList: true, subtree: true });
observer.observe(root, { childList: true, subtree: true });
});
}

View File

@@ -447,7 +447,10 @@ export async function loadDashboard(forceFullRender = false) {
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const subtitle = subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : '';
return `<div class="dashboard-target dashboard-autostart" data-target-id="${target.id}">
const asNavSub = isLed ? 'led' : 'key_colors';
const asNavSec = isLed ? 'led-targets' : 'kc-targets';
const asNavAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-target-id="${target.id}" onclick="if(!event.target.closest('button')){navigateToCard('targets','${asNavSub}','${asNavSec}','${asNavAttr}','${target.id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_AUTOSTART}</span>
<div>
@@ -551,6 +554,10 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const icon = ICON_TARGET;
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
const navSubTab = isLed ? 'led' : 'key_colors';
const navSection = isLed ? 'led-targets' : 'kc-targets';
const navAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
const navOnclick = `if(!event.target.closest('button')){navigateToCard('targets','${navSubTab}','${navSection}','${navAttr}','${target.id}')}`;
let subtitleParts = [typeLabel];
if (isLed) {
@@ -590,7 +597,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
healthDot = `<span class="health-dot ${cls}"></span>`;
}
return `<div class="dashboard-target" data-target-id="${target.id}">
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
@@ -620,7 +627,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
</div>
</div>`;
} else {
return `<div class="dashboard-target">
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
@@ -665,7 +672,7 @@ function renderDashboardProfile(profile) {
const activeCount = (profile.active_target_ids || []).length;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
return `<div class="dashboard-target dashboard-profile" data-profile-id="${profile.id}">
return `<div class="dashboard-target dashboard-profile dashboard-card-link" data-profile-id="${profile.id}" onclick="if(!event.target.closest('button')){navigateToCard('profiles',null,'profiles','data-profile-id','${profile.id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_PROFILE}</span>
<div>

View File

@@ -26,7 +26,7 @@ class ProfileEditorModal extends Modal {
}
const profileModal = new ProfileEditorModal();
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()" });
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()", keyAttr: 'data-profile-id' });
// Re-render profiles when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
@@ -79,7 +79,7 @@ export async function loadProfiles() {
function renderProfiles(profiles, runningTargetIds = new Set()) {
const container = document.getElementById('profiles-content');
const items = profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) }));
const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) })));
container.innerHTML = csProfiles.render(items);
csProfiles.bind();

View File

@@ -43,15 +43,15 @@ import {
} from '../core/icons.js';
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()" });
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')" });
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()" });
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" });
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" });
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()" });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()" });
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' });
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id' });
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id' });
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id' });
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id' });
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -1385,21 +1385,21 @@ function renderPictureSourcesList(streams) {
if (tab.key === 'raw') {
panelContent =
csRawStreams.render(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) +
csRawTemplates.render(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
csRawStreams.render(csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))) +
csRawTemplates.render(csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) }))));
} else if (tab.key === 'processed') {
panelContent =
csProcStreams.render(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) +
csProcTemplates.render(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
csProcStreams.render(csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))) +
csProcTemplates.render(csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) }))));
} else if (tab.key === 'audio') {
panelContent =
csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
csAudioTemplates.render(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
csAudioMulti.render(csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })))) +
csAudioMono.render(csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })))) +
csAudioTemplates.render(csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) }))));
} else if (tab.key === 'value') {
panelContent = csValueSources.render(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
panelContent = csValueSources.render(csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))));
} else {
panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
panelContent = csStaticStreams.render(csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))));
}
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;

View File

@@ -581,12 +581,12 @@ export async function loadTargetsTab() {
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// Build items arrays for each section
const deviceItems = ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) }));
const cssItems = Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) }));
const ledTargetItems = ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) }));
const kcTargetItems = kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) }));
const patternItems = patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) }));
// Build items arrays for each section (apply saved drag order)
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
const cssItems = csColorStrips.applySortOrder(Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds = null;