369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
|
|
import { DSP408 } from '../../../../types';
|
|
import { Channel, InputChannel, OutputChannel } from '../../types';
|
|
import Button from '../../../../components/Button/Button';
|
|
import Card from '../../../../components/Card/Card';
|
|
|
|
import './LinkChannelsModal.css';
|
|
|
|
const INPUT_CHANNELS: readonly InputChannel[] = Object.values(InputChannel);
|
|
const OUTPUT_CHANNELS: readonly OutputChannel[] = Object.values(OutputChannel);
|
|
|
|
type LinkMode = 'input' | 'output';
|
|
|
|
type LinkGroup = {
|
|
source: Channel;
|
|
destinations: Channel[];
|
|
};
|
|
|
|
/**
|
|
* Maps every channel (by its raw enum string) to the channel it currently
|
|
* follows. A channel that follows itself is unlinked. Several channels can
|
|
* follow the same leader (many-to-one), but a channel that is itself
|
|
* following someone can't also be followed by others - no chains.
|
|
*/
|
|
type LeaderMap = Record<string, string>;
|
|
|
|
const wrapInputChannel = (raw: string): Channel => ({ Input: raw as InputChannel });
|
|
const wrapOutputChannel = (raw: string): Channel => ({ Output: raw as OutputChannel });
|
|
|
|
/**
|
|
* Gets the raw enum string (InA, Out1, ...) out of a channel entry.
|
|
* The device state has been observed to store these as plain strings
|
|
* (e.g. "InA") rather than the wrapped { Input: ... } / { Output: ... }
|
|
* shape used elsewhere, so this accepts either form defensively.
|
|
*/
|
|
function channelRawId(channel: Channel | string): string {
|
|
if (typeof channel === 'string') {
|
|
return channel;
|
|
}
|
|
|
|
return 'Input' in channel ? channel.Input : channel.Output;
|
|
}
|
|
|
|
/**
|
|
* Breaks any chains that might be present in a leader map (a channel that
|
|
* both follows someone and is followed by others) by releasing whoever
|
|
* follows a follower back to themselves. Keeps the invariant - a channel
|
|
* can lead or follow, never both - true regardless of what the device
|
|
* reported.
|
|
*/
|
|
function sanitizeLeaderMap(leaderOf: LeaderMap): LeaderMap {
|
|
const next = { ...leaderOf };
|
|
|
|
Object.keys(next).forEach((channel) => {
|
|
const leader = next[channel];
|
|
|
|
if (leader !== channel && next[leader] !== leader) {
|
|
// `channel` follows a leader that itself follows someone else -
|
|
// release `channel` back to itself to break the chain.
|
|
next[channel] = channel;
|
|
}
|
|
});
|
|
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Reads the DSP's real link state (per-channel `linked_channels: Channel[]`)
|
|
* and turns it into a leader map, so the modal opens already reflecting the
|
|
* device. Every channel defaults to following itself (unlinked); channels
|
|
* reported as linked follow whichever channel the device reported first.
|
|
*/
|
|
function extractLeaderMap<T extends string>(
|
|
states: Partial<Record<T, { linked_channels: (Channel | string)[] }>> | undefined,
|
|
orderedChannels: readonly T[]
|
|
): LeaderMap {
|
|
const leaderOf: LeaderMap = {};
|
|
|
|
orderedChannels.forEach((channel) => {
|
|
leaderOf[channel] = channel;
|
|
});
|
|
|
|
if (!states) {
|
|
return leaderOf;
|
|
}
|
|
|
|
orderedChannels.forEach((channel) => {
|
|
const state = states[channel];
|
|
|
|
if (!state || state.linked_channels.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const leaderId = channelRawId(state.linked_channels[0]);
|
|
|
|
if (leaderId !== channel) {
|
|
leaderOf[channel] = leaderId;
|
|
}
|
|
});
|
|
|
|
return sanitizeLeaderMap(leaderOf);
|
|
}
|
|
|
|
/**
|
|
* Turns a leader map into one linkChannels call per leader that has
|
|
* followers (source = leader, destinations = its followers), plus one call
|
|
* per still-unlinked, follower-less channel with no destinations. Channels
|
|
* that are themselves following someone don't get their own call - they're
|
|
* already covered by their leader's call.
|
|
*/
|
|
function buildLinkCalls(
|
|
leaderOf: LeaderMap,
|
|
orderedChannels: readonly string[],
|
|
wrap: (raw: string) => Channel
|
|
): LinkGroup[] {
|
|
const followersByLeader = new Map<string, string[]>();
|
|
|
|
orderedChannels.forEach((channel) => {
|
|
const leader = leaderOf[channel];
|
|
|
|
if (leader === channel) {
|
|
return;
|
|
}
|
|
|
|
const existing = followersByLeader.get(leader) ?? [];
|
|
existing.push(channel);
|
|
followersByLeader.set(leader, existing);
|
|
});
|
|
|
|
const calls: LinkGroup[] = [];
|
|
|
|
orderedChannels.forEach((channel) => {
|
|
const followers = followersByLeader.get(channel);
|
|
|
|
if (followers) {
|
|
calls.push({ source: wrap(channel), destinations: followers.map(wrap) });
|
|
return;
|
|
}
|
|
|
|
if (leaderOf[channel] === channel) {
|
|
calls.push({ source: wrap(channel), destinations: [] });
|
|
}
|
|
});
|
|
|
|
return calls;
|
|
}
|
|
|
|
function LinkChannelsModal({
|
|
dsp,
|
|
onClose,
|
|
linkChannels,
|
|
}: {
|
|
dsp: DSP408;
|
|
onClose: () => void;
|
|
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
|
|
}) {
|
|
const [mode, setMode] = useState<LinkMode>('input');
|
|
|
|
const [inputLeaders, setInputLeaders] = useState<LeaderMap>(() =>
|
|
extractLeaderMap(dsp.state?.current_config?.input_states, INPUT_CHANNELS)
|
|
);
|
|
|
|
const [outputLeaders, setOutputLeaders] = useState<LeaderMap>(() =>
|
|
extractLeaderMap(dsp.state?.current_config?.output_states, OUTPUT_CHANNELS)
|
|
);
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const channels: readonly string[] = mode === 'input' ? INPUT_CHANNELS : OUTPUT_CHANNELS;
|
|
const leaderOf = mode === 'input' ? inputLeaders : outputLeaders;
|
|
const setLeaderOf = mode === 'input' ? setInputLeaders : setOutputLeaders;
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
const followChannel = (follower: string, leader: string) => {
|
|
setLeaderOf((prev) => {
|
|
const next = { ...prev };
|
|
|
|
// `follower` can no longer lead anyone once it follows `leader` -
|
|
// release whoever currently follows `follower` back to themselves.
|
|
Object.keys(next).forEach((channel) => {
|
|
if (next[channel] === follower) {
|
|
next[channel] = channel;
|
|
}
|
|
});
|
|
|
|
next[follower] = leader;
|
|
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const unlinkChannel = (channel: string) => {
|
|
setLeaderOf((prev) => ({ ...prev, [channel]: channel }));
|
|
};
|
|
|
|
const handleCellClick = (row: string, col: string) => {
|
|
const isSelf = row === col;
|
|
|
|
if (isSelf) {
|
|
if (leaderOf[row] !== row) {
|
|
unlinkChannel(row);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (leaderOf[row] !== row || leaderOf[col] === row) {
|
|
// Row is locked (it's itself following someone) or this pair is
|
|
// already the current state - nothing to do.
|
|
return;
|
|
}
|
|
|
|
followChannel(col, row);
|
|
};
|
|
|
|
const handleValidate = async () => {
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
const groups = [
|
|
...buildLinkCalls(inputLeaders, INPUT_CHANNELS, wrapInputChannel),
|
|
...buildLinkCalls(outputLeaders, OUTPUT_CHANNELS, wrapOutputChannel),
|
|
];
|
|
|
|
for (const group of groups) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await linkChannels(group.source, group.destinations);
|
|
}
|
|
|
|
onClose();
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="link-channels-overlay"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<Card className="link-channels-modal">
|
|
<div className="link-channels-modal__header">
|
|
<h2>Link Channels</h2>
|
|
<p>
|
|
Link one or more channels to a single channel. A channel not linked to another is linked
|
|
to itself.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="link-channels-modal__mode-toggle" role="tablist" aria-label="Channel type">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mode === 'input'}
|
|
className={`link-channels-modal__mode-btn ${mode === 'input' ? 'is-active' : ''}`}
|
|
onClick={() => setMode('input')}
|
|
>
|
|
Input
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mode === 'output'}
|
|
className={`link-channels-modal__mode-btn ${mode === 'output' ? 'is-active' : ''}`}
|
|
onClick={() => setMode('output')}
|
|
>
|
|
Output
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className="link-matrix"
|
|
style={{ gridTemplateColumns: `auto repeat(${channels.length}, 1fr)` }}
|
|
>
|
|
<div className="link-matrix__corner" />
|
|
|
|
{channels.map((col) => (
|
|
<div className="link-matrix__col-label" key={`col-${col}`}>
|
|
{col}
|
|
</div>
|
|
))}
|
|
|
|
{channels.map((row, rowIndex) => {
|
|
const isRowLocked = leaderOf[row] !== row;
|
|
|
|
return (
|
|
<div className="link-matrix__row" key={`row-${row}`}>
|
|
<div className="link-matrix__row-label">{row}</div>
|
|
|
|
{channels.map((col, colIndex) => {
|
|
if (colIndex < rowIndex) {
|
|
return (
|
|
<div
|
|
className="link-matrix__cell link-matrix__cell--empty"
|
|
key={`cell-${row}-${col}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const isSelf = row === col;
|
|
const isActive = isSelf ? leaderOf[row] === row : leaderOf[col] === row;
|
|
const isUnavailable = !isSelf && !isActive && isRowLocked;
|
|
|
|
const label = isSelf
|
|
? isActive
|
|
? `${row} not linked`
|
|
: `Unlink ${row}`
|
|
: isUnavailable
|
|
? `${row} is already linked to another channel`
|
|
: isActive
|
|
? `${col} linked to ${row}`
|
|
: `Link ${col} to ${row}`;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={`cell-${row}-${col}`}
|
|
className={[
|
|
'link-matrix__cell',
|
|
'link-matrix__cell--toggle',
|
|
isUnavailable && 'link-matrix__cell--unavailable',
|
|
isActive && 'is-active',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
aria-pressed={isActive}
|
|
disabled={isActive || isUnavailable}
|
|
aria-label={label}
|
|
onClick={() => handleCellClick(row, col)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="link-channels-modal__actions">
|
|
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
|
Cancel
|
|
</Button>
|
|
|
|
<Button variant="outline" onClick={handleValidate} disabled={isSaving}>
|
|
{isSaving ? 'Linking...' : 'Validate'}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LinkChannelsModal;
|