onChange(swatch)}
+ onClick={() => choose(swatch)}
/>
);
})}
@@ -149,7 +165,7 @@ export const ColorPalette = ({
!hexValid &&
'border-red-500 focus:border-red-500 focus:ring-red-500',
)}
- value={hexInput}
+ value={hexText}
spellCheck={false}
placeholder="#rrggbb"
onChange={(evt) => handleHexChange(evt.target.value)}
@@ -160,11 +176,12 @@ export const ColorPalette = ({
// override the resting border back to transparent
className={clsx(
'shrink-0 rounded border border-gray-300 px-2 py-1 transition-colors',
- 'hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-opacity-30',
+ 'hover:bg-gray-200 focus:outline-none',
+ 'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-opacity-30',
'dark:border-slate-500 dark:hover:bg-slate-700',
)}
title="Restore the automatically assigned color"
- onClick={onReset}
+ onClick={reset}
>
Reset
diff --git a/client/src/components/views/GraphView/Graph.ts b/client/src/components/views/GraphView/Graph.ts
index 6e60a7530..99684dcdb 100644
--- a/client/src/components/views/GraphView/Graph.ts
+++ b/client/src/components/views/GraphView/Graph.ts
@@ -237,7 +237,8 @@ export default class Graph {
}
reset() {
- this.data = {};
+ // no prototype, so a series named '__proto__' is stored like any other
+ this.data = Object.create(null);
this.beginGraphNowMs = Number.NaN; // in telemetry time
this.beginRenderTimeMs = Number.NaN; // in browser time
@@ -265,8 +266,8 @@ export default class Graph {
colorFor(name: string) {
const o = this.options;
- const override = o.seriesColors[name];
- if (override) return override;
+ if (Object.prototype.hasOwnProperty.call(o.seriesColors, name))
+ return o.seriesColors[name];
const orderIndex = o.seriesOrder.indexOf(name);
const index =
@@ -565,13 +566,5 @@ export default class Graph {
setOptions(options: Options) {
Object.assign(this.options, options);
-
- // Dropped from the layer order means no longer graphed, so forget it.
- const { seriesOrder } = this.options;
- if (seriesOrder.length === 0) return;
-
- for (const name of Object.keys(this.data)) {
- if (!seriesOrder.includes(name)) delete this.data[name];
- }
}
}
diff --git a/client/src/components/views/GraphView/GraphCanvas.jsx b/client/src/components/views/GraphView/GraphCanvas.jsx
index 3c2784080..8d7f50392 100644
--- a/client/src/components/views/GraphView/GraphCanvas.jsx
+++ b/client/src/components/views/GraphView/GraphCanvas.jsx
@@ -59,6 +59,10 @@ class GraphCanvas extends React.Component {
}
renderGraph() {
+ // Option changes call this while a frame is already queued; without the
+ // cancel each one would leave another loop running.
+ if (this.requestId) cancelAnimationFrame(this.requestId);
+
if (this.props.paused) {
// Option changes made while paused are visible without resuming.
this.graph.render(this.props.pausedTime);
diff --git a/client/src/components/views/GraphView/GraphSeriesList.tsx b/client/src/components/views/GraphView/GraphSeriesList.tsx
index 1a7118e7e..866c9287e 100644
--- a/client/src/components/views/GraphView/GraphSeriesList.tsx
+++ b/client/src/components/views/GraphView/GraphSeriesList.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import { flushSync } from 'react-dom';
import clsx from 'clsx';
import { ReactComponent as ExpandMoreIcon } from '@/assets/icons/expand_more.svg';
@@ -40,6 +41,9 @@ const GraphSeriesList = ({
const [dragIndex, setDragIndex] = useState
(null);
const [dropIndex, setDropIndex] = useState(null);
+ // Otherwise the palette reopens, and takes focus, when the key comes back.
+ if (openKey !== null && !seriesKeys.includes(openKey)) setOpenKey(null);
+
const reorderable = seriesKeys.length > 1;
const move = (from: number, to: number) => {
@@ -47,6 +51,20 @@ const GraphSeriesList = ({
if (reordered !== seriesKeys) onReorder(reordered);
};
+ const moveWithButton = (
+ button: HTMLButtonElement,
+ from: number,
+ to: number,
+ ) => {
+ const hadFocus = button === document.activeElement;
+ flushSync(() => move(from, to));
+
+ // Landing at either end disables the button just used, dropping its focus.
+ const row = button.closest('li');
+ if (hadFocus && button.disabled)
+ row?.querySelector('button:enabled')?.focus();
+ };
+
const endDrag = () => {
setDragIndex(null);
setDropIndex(null);
@@ -89,8 +107,9 @@ const GraphSeriesList = ({
onDragStart={(evt) => {
setDragIndex(i);
evt.dataTransfer.effectAllowed = 'move';
- // Firefox ignores drags that carry no data
- evt.dataTransfer.setData('text/plain', key);
+ // Firefox ignores drags that carry no data; a private type keeps
+ // text fields from taking a missed drop as typed text
+ evt.dataTransfer.setData('application/x-ftc-graph-series', key);
}}
onDragEnd={endDrag}
className={clsx(
@@ -115,7 +134,7 @@ const GraphSeriesList = ({
title={`Move ${key} one layer forward`}
aria-label={`Move ${key} one layer forward`}
disabled={i === 0}
- onClick={() => move(i, i - 1)}
+ onClick={(evt) => moveWithButton(evt.currentTarget, i, i - 1)}
>
@@ -125,7 +144,7 @@ const GraphSeriesList = ({
title={`Move ${key} one layer back`}
aria-label={`Move ${key} one layer back`}
disabled={i === seriesKeys.length - 1}
- onClick={() => move(i, i + 1)}
+ onClick={(evt) => moveWithButton(evt.currentTarget, i, i + 1)}
>
diff --git a/client/src/components/views/GraphView/GraphView.tsx b/client/src/components/views/GraphView/GraphView.tsx
index 8397973ba..af216ff28 100644
--- a/client/src/components/views/GraphView/GraphView.tsx
+++ b/client/src/components/views/GraphView/GraphView.tsx
@@ -37,8 +37,8 @@ type GraphViewState = {
// selection doubles as the layer order: the first key is drawn in front
selectedKeys: string[];
// colors are remembered per key, including for keys that are unchecked and
- // later re-checked
- keyColors: { [key: string]: string };
+ // later re-checked; a Map because keys like 'constructor' come from the robot
+ keyColors: ReadonlyMap;
showSeriesSettings: boolean;
windowMs: ValResult;
};
@@ -67,7 +67,7 @@ class GraphView extends Component {
pausedTime: 0,
availableKeys: [],
selectedKeys: [],
- keyColors: {},
+ keyColors: new Map(),
showSeriesSettings: false,
windowMs: {
value: DEFAULT_OPTIONS.windowMs,
@@ -96,7 +96,9 @@ class GraphView extends Component {
// activate that button and stop graphing instead of toggling pause.
keepFocusOnView(evt: React.MouseEvent) {
evt.preventDefault();
- this.containerRef.current?.focus();
+ // Scrolling the tile into view would move the button from under the
+ // pointer and lose the click.
+ this.containerRef.current?.focus({ preventScroll: true });
}
componentDidMount() {
@@ -152,7 +154,7 @@ class GraphView extends Component {
handleSelectionChange(selectedKeys: string[]) {
this.setState((state) => {
- const keyColors = { ...state.keyColors };
+ const keyColors = new Map(state.keyColors);
// Only arriving keys need a color picked; only theirs can collide.
const staying = selectedKeys.filter((key) =>
@@ -163,19 +165,19 @@ class GraphView extends Component {
);
const used = staying
- .map((key) => keyColors[key])
+ .map((key) => keyColors.get(key))
.filter((color): color is string => color !== undefined);
for (const key of arriving) {
// A returning key keeps its old color unless another line took it.
- const remembered = keyColors[key];
+ const remembered = keyColors.get(key);
const color =
remembered !== undefined &&
!used.some((taken) => sameColor(taken, remembered))
? remembered
: pickDefaultColor(used);
- keyColors[key] = color;
+ keyColors.set(key, color);
used.push(color);
}
@@ -183,13 +185,21 @@ class GraphView extends Component {
});
}
- handleReorder(selectedKeys: string[]) {
- this.setState({ selectedKeys });
+ // Only the shown keys move; absent ones keep their slots in the selection.
+ handleReorder(shownKeys: string[]) {
+ this.setState((state) => {
+ let next = 0;
+ return {
+ selectedKeys: state.selectedKeys.map((key) =>
+ shownKeys.includes(key) ? shownKeys[next++] : key,
+ ),
+ };
+ });
}
handleColorChange(key: string, color: string) {
this.setState((state) => ({
- keyColors: { ...state.keyColors, [key]: color },
+ keyColors: new Map(state.keyColors).set(key, color),
}));
}
@@ -197,11 +207,11 @@ class GraphView extends Component {
this.setState((state) => {
const used = state.selectedKeys
.filter((k) => k !== key)
- .map((k) => state.keyColors[k])
+ .map((k) => state.keyColors.get(k))
.filter((color): color is string => color !== undefined);
return {
- keyColors: { ...state.keyColors, [key]: pickDefaultColor(used) },
+ keyColors: new Map(state.keyColors).set(key, pickDefaultColor(used)),
};
});
}
@@ -280,17 +290,30 @@ class GraphView extends Component {
});
}
- renderSeriesList(seriesColors: { [key: string]: string }) {
+ // Keys the current op mode is not sending stay selected, so they come back
+ // checked when it sends them again, but have no line to edit until then.
+ // Every op mode init and telemetry.clear() empties availableKeys, so until
+ // the next packet the selection stands in for it.
+ shownKeys() {
+ const { selectedKeys, availableKeys } = this.state;
+ if (availableKeys.length === 0) return selectedKeys;
+ return selectedKeys.filter((key) => availableKeys.includes(key));
+ }
+
+ renderSeriesList(
+ shownKeys: string[],
+ seriesColors: { [key: string]: string },
+ ) {
return (
<>
- {this.state.selectedKeys.length > 1 && (
+ {shownKeys.length > 1 && (
The first line is drawn in front of the others.
@@ -302,7 +325,9 @@ class GraphView extends Component {
// colors of the graphed keys only; the rest are remembered but unused
seriesColors() {
return Object.fromEntries(
- this.state.selectedKeys.map((key) => [key, this.state.keyColors[key]]),
+ [...this.state.keyColors].filter(([key]) =>
+ this.state.selectedKeys.includes(key),
+ ),
);
}
@@ -328,6 +353,7 @@ class GraphView extends Component {
}),
]);
+ const shownKeys = this.shownKeys();
const seriesColors = this.seriesColors();
return (
@@ -342,48 +368,46 @@ class GraphView extends Component {
Graph
+ {this.state.graphing && shownKeys.length !== 0 && (
+
+ this.setState((state) => ({
+ showSeriesSettings: !state.showSeriesSettings,
+ }))
+ }
+ >
+
+
+ )}
+
{this.state.graphing && this.state.selectedKeys.length !== 0 && (
- <>
-
- this.setState((state) => ({
- showSeriesSettings: !state.showSeriesSettings,
- }))
- }
- >
-
-
-
-
- {this.state.userPaused ? (
-
- ) : (
-
- )}
-
- >
+
+ {this.state.userPaused ? (
+
+ ) : (
+
+ )}
+
)}
{
selected={this.state.selectedKeys}
/>
- {this.state.selectedKeys.length !== 0 && (
+ {shownKeys.length !== 0 && (