Skip to content

Commit a66b451

Browse files
committed
feat: implement multiview layout panels with draggable functionality and integrate with routing feedback
1 parent 479080d commit a66b451

10 files changed

Lines changed: 623 additions & 63 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
.canvas {
2+
position: relative;
3+
width: 100%;
4+
background: #111;
5+
overflow: hidden;
6+
}
7+
8+
.canvasLight {
9+
background: #ddd;
10+
}
11+
12+
.tile {
13+
position: absolute;
14+
box-sizing: border-box;
15+
border: 1px solid rgba(255, 255, 255, 0.35);
16+
display: flex;
17+
align-items: flex-end;
18+
padding: 3px 5px;
19+
font-size: 0.68rem;
20+
color: #fff;
21+
background-color: rgba(13, 110, 253, 0.35);
22+
cursor: pointer;
23+
transition: outline-color 0.1s, background-color 0.1s;
24+
outline: 2px solid transparent;
25+
outline-offset: -2px;
26+
27+
&:hover {
28+
background-color: rgba(13, 110, 253, 0.5);
29+
}
30+
}
31+
32+
.tileEmpty {
33+
background-color: rgba(120, 120, 120, 0.15);
34+
35+
&:hover {
36+
background-color: rgba(120, 120, 120, 0.28);
37+
}
38+
}
39+
40+
.tileSelected {
41+
outline-color: #ffc107;
42+
background-color: rgba(255, 193, 7, 0.45);
43+
}
44+
45+
.tileLabel {
46+
overflow: hidden;
47+
text-overflow: ellipsis;
48+
white-space: nowrap;
49+
width: 100%;
50+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
51+
}
52+
53+
.tileNumberBadge {
54+
position: absolute;
55+
top: 2px;
56+
left: 3px;
57+
font-size: 0.62rem;
58+
opacity: 0.75;
59+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
60+
}
61+
62+
.canvasMeta {
63+
font-size: 0.68rem;
64+
}
65+
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { MultiviewLayoutState, MultiviewTileState } from "../store/apiSlice";
2+
import styles from "./MultiviewLayoutCanvas.module.scss";
3+
4+
export interface MultiviewLayoutCanvasProps {
5+
/** Current multiview canvas/tile layout to render. */
6+
layout: MultiviewLayoutState;
7+
/** Resolves a device key (e.g. a tile's sourceDeviceKey) to a display name. */
8+
resolveSourceName: (deviceKey: string) => string;
9+
darkMode?: boolean;
10+
/** Tile number to render as selected/highlighted, or null/undefined if none. */
11+
selectedTileNumber?: number | null;
12+
/** Called when a tile is clicked, with the full tile state. */
13+
onTileClick?: (tile: MultiviewTileState) => void;
14+
}
15+
16+
/**
17+
* Renders a visual mock-up of what is actually displayed on the monitor fed by a single
18+
* multiview-capable decoder: its canvas at the correct aspect ratio, with every tile
19+
* positioned/sized/stacked to match and labeled with its routed source. Used inside a per-node
20+
* popover in RoutingDeviceNode - purely additive, it does not alter the existing tie-line/route
21+
* graph visualization in Routing.tsx.
22+
*/
23+
const MultiviewLayoutCanvas = ({
24+
layout,
25+
resolveSourceName,
26+
darkMode,
27+
selectedTileNumber,
28+
onTileClick,
29+
}: MultiviewLayoutCanvasProps) => {
30+
const aspectRatio = layout.canvasWidth / layout.canvasHeight;
31+
32+
return (
33+
<div>
34+
<div className={`text-muted mb-1 ${styles.canvasMeta}`}>
35+
{layout.canvasWidth}&times;{layout.canvasHeight}
36+
</div>
37+
<div
38+
className={`${styles.canvas}${darkMode ? "" : ` ${styles.canvasLight}`}`}
39+
style={{ aspectRatio: Number.isFinite(aspectRatio) && aspectRatio > 0 ? aspectRatio : 16 / 9 }}
40+
>
41+
{[...layout.tiles]
42+
.sort((a, b) => a.zOrder - b.zOrder)
43+
.map((tile) => {
44+
const isEmpty = !tile.sourceDeviceKey;
45+
const isSelected = selectedTileNumber === tile.tileNumber;
46+
const sourceName = tile.sourceDeviceKey
47+
? resolveSourceName(tile.sourceDeviceKey)
48+
: "Empty";
49+
50+
return (
51+
<div
52+
key={tile.tileNumber}
53+
className={`${styles.tile}${isEmpty ? ` ${styles.tileEmpty}` : ""}${isSelected ? ` ${styles.tileSelected}` : ""}`}
54+
style={{
55+
left: `${(tile.x / layout.canvasWidth) * 100}%`,
56+
top: `${(tile.y / layout.canvasHeight) * 100}%`,
57+
width: `${(tile.width / layout.canvasWidth) * 100}%`,
58+
height: `${(tile.height / layout.canvasHeight) * 100}%`,
59+
zIndex: tile.zOrder,
60+
}}
61+
title={`Tile ${tile.tileNumber}: ${sourceName}`}
62+
onClick={(e) => {
63+
e.stopPropagation();
64+
onTileClick?.(tile);
65+
}}
66+
>
67+
<span className={styles.tileNumberBadge}>{tile.tileNumber}</span>
68+
<span className={styles.tileLabel}>{sourceName}</span>
69+
</div>
70+
);
71+
})}
72+
</div>
73+
</div>
74+
);
75+
};
76+
77+
export default MultiviewLayoutCanvas;
78+
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
.panel {
2+
position: absolute;
3+
width: 280px;
4+
z-index: 20;
5+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
6+
border-radius: 6px;
7+
overflow: hidden;
8+
background: #fff;
9+
border: 1px solid #ccc;
10+
}
11+
12+
.panelDark {
13+
background: #1e1e1e;
14+
border-color: #444;
15+
color: #e0e0e0;
16+
}
17+
18+
.titleBar {
19+
display: flex;
20+
align-items: center;
21+
justify-content: space-between;
22+
padding: 0.35rem 0.5rem;
23+
font-size: 0.78rem;
24+
font-weight: 600;
25+
cursor: move;
26+
-webkit-user-select: none;
27+
user-select: none;
28+
background: #e9ecef;
29+
border-bottom: 1px solid #ccc;
30+
}
31+
32+
.titleBarDark {
33+
background: #2c2c2c;
34+
border-bottom-color: #444;
35+
color: #e0e0e0;
36+
}
37+
38+
.title {
39+
overflow: hidden;
40+
text-overflow: ellipsis;
41+
white-space: nowrap;
42+
flex-grow: 1;
43+
margin-right: 0.5rem;
44+
}
45+
46+
.closeBtn {
47+
flex-shrink: 0;
48+
background: none;
49+
border: none;
50+
padding: 0 2px;
51+
line-height: 1;
52+
font-size: 1.1rem;
53+
color: inherit;
54+
opacity: 0.7;
55+
cursor: pointer;
56+
57+
&:hover {
58+
opacity: 1;
59+
color: #dc3545;
60+
}
61+
}
62+
63+
.body {
64+
padding: 0.5rem;
65+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { useCallback, useRef } from "react";
2+
3+
import { MultiviewLayoutState, MultiviewTileState } from "../store/apiSlice";
4+
import MultiviewLayoutCanvas from "./MultiviewLayoutCanvas";
5+
import styles from "./MultiviewLayoutPanel.module.scss";
6+
7+
export interface MultiviewLayoutPanelPosition {
8+
x: number;
9+
y: number;
10+
}
11+
12+
export interface MultiviewLayoutPanelProps {
13+
title: string;
14+
layout: MultiviewLayoutState;
15+
position: MultiviewLayoutPanelPosition;
16+
darkMode?: boolean;
17+
selectedTileNumber?: number | null;
18+
resolveSourceName: (deviceKey: string) => string;
19+
onTileClick?: (tile: MultiviewTileState) => void;
20+
onClose: () => void;
21+
onMove: (position: MultiviewLayoutPanelPosition) => void;
22+
}
23+
24+
/**
25+
* A floating, freely-draggable window showing a single device's multiview canvas/tile mock-up
26+
* (via MultiviewLayoutCanvas). Rendered as a sibling of the React Flow canvas (not as part of a
27+
* graph node), so its position is independent of node positions - which move whenever the dagre
28+
* layout re-runs in response to routing/filter changes. Stays open until explicitly closed, and
29+
* multiple panels (one per device) can be open and dragged around independently at once.
30+
*/
31+
const MultiviewLayoutPanel = ({
32+
title,
33+
layout,
34+
position,
35+
darkMode,
36+
selectedTileNumber,
37+
resolveSourceName,
38+
onTileClick,
39+
onClose,
40+
onMove,
41+
}: MultiviewLayoutPanelProps) => {
42+
const dragStateRef = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>(null);
43+
44+
const handleTitleBarPointerDown = useCallback(
45+
(e: React.PointerEvent<HTMLDivElement>) => {
46+
// Only left-click / primary touch should start a drag.
47+
if (e.button !== 0) return;
48+
49+
dragStateRef.current = {
50+
startX: e.clientX,
51+
startY: e.clientY,
52+
originX: position.x,
53+
originY: position.y,
54+
};
55+
56+
const handlePointerMove = (moveEvent: PointerEvent) => {
57+
const dragState = dragStateRef.current;
58+
if (!dragState) return;
59+
onMove({
60+
x: dragState.originX + (moveEvent.clientX - dragState.startX),
61+
y: dragState.originY + (moveEvent.clientY - dragState.startY),
62+
});
63+
};
64+
65+
const handlePointerUp = () => {
66+
dragStateRef.current = null;
67+
window.removeEventListener("pointermove", handlePointerMove);
68+
window.removeEventListener("pointerup", handlePointerUp);
69+
};
70+
71+
window.addEventListener("pointermove", handlePointerMove);
72+
window.addEventListener("pointerup", handlePointerUp);
73+
},
74+
[position.x, position.y, onMove],
75+
);
76+
77+
return (
78+
<div
79+
className={`${styles.panel}${darkMode ? ` ${styles.panelDark}` : ""}`}
80+
style={{ left: position.x, top: position.y }}
81+
>
82+
<div
83+
className={`${styles.titleBar}${darkMode ? ` ${styles.titleBarDark}` : ""}`}
84+
onPointerDown={handleTitleBarPointerDown}
85+
>
86+
<span className={styles.title} title={title}>
87+
{title}
88+
</span>
89+
<button className={styles.closeBtn} onClick={onClose} title="Close">
90+
&times;
91+
</button>
92+
</div>
93+
<div className={styles.body}>
94+
<MultiviewLayoutCanvas
95+
layout={layout}
96+
resolveSourceName={resolveSourceName}
97+
darkMode={darkMode}
98+
selectedTileNumber={selectedTileNumber}
99+
onTileClick={onTileClick}
100+
/>
101+
</div>
102+
</div>
103+
);
104+
};
105+
106+
export default MultiviewLayoutPanel;

0 commit comments

Comments
 (0)