Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions client/src/assets/icons/drag_indicator.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
191 changes: 191 additions & 0 deletions client/src/components/views/GraphView/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { useEffect, useId, useRef, useState } from 'react';
import clsx from 'clsx';

import { normalizeHexColor, sameColor, SWATCH_COLORS } from './colors';

type ColorSwatchButtonProps = {
color: string;
title: string;
expanded: boolean;
onClick: () => void;
};

export const ColorSwatchButton = ({
color,
title,
expanded,
onClick,
}: ColorSwatchButtonProps) => (
<button
type="button"
className="icon-btn h-6 w-6 shrink-0 p-0.5"
title={title}
aria-label={title}
aria-expanded={expanded}
onClick={onClick}
>
<span
className="h-full w-full rounded-sm ring-1 ring-inset ring-black/20"
style={{ backgroundColor: color }}
/>
</button>
);

type ColorPaletteProps = {
color: string;
onChange: (color: string) => void;
onReset: () => void;
onClose: () => void;
};

export const ColorPalette = ({
color,
onChange,
onReset,
onClose,
}: ColorPaletteProps) => {
const id = useId();

// null shows `color` itself
const [hexInput, setHexInput] = useState<string | null>(null);
const hexText = hexInput ?? color;
const hexValid = normalizeHexColor(hexText) !== null;

// Leave the field alone while it already spells the current color, or
// normalizing '#abc' under the cursor makes shorthand impossible to type.
useEffect(
() =>
setHexInput((current) =>
current !== null &&
normalizeHexColor(current) !== null &&
sameColor(current, color)
? current
: null,
),
[color],
);

const containerRef = useRef<HTMLDivElement>(null);

// Focus the palette so Escape works, and hand focus back on close so the
// view's shortcuts still reach.
useEffect(() => {
const previous = document.activeElement;
containerRef.current?.focus();

return () => {
// Only when closing dropped focus on the floor; switching rows leaves
// the other row's swatch focused.
const active = document.activeElement;
if (active !== null && active !== document.body) return;

if (previous instanceof HTMLElement && previous.isConnected)
previous.focus();
};
}, []);

const handleHexChange = (raw: string) => {
setHexInput(raw);

const normalized = normalizeHexColor(raw);
if (normalized !== null) onChange(normalized);
};

// Drop typed text here rather than in the effect above, which runs a frame
// late and not at all when the chosen color is the current one.
const choose = (swatch: string) => {
setHexInput(null);
onChange(swatch);
};

const reset = () => {
setHexInput(null);
onReset();
};

return (
<div
ref={containerRef}
className={clsx(
'mt-1 mb-2 w-max max-w-full rounded border border-gray-200 bg-gray-50 p-2',
'focus:outline-none dark:border-slate-600 dark:bg-slate-800',
)}
tabIndex={-1}
onKeyDown={(evt) => {
if (evt.key === 'Escape') {
evt.stopPropagation();
onClose();
}
}}
>
<div className="grid w-max grid-cols-8 gap-1">
{SWATCH_COLORS.map((swatch) => {
const selected = sameColor(swatch, color);

return (
<button
type="button"
key={swatch}
className={clsx(
'h-5 w-5 rounded-sm ring-inset transition hover:scale-110',
selected
? 'ring-2 ring-gray-900 dark:ring-white'
: 'ring-1 ring-black/20',
)}
style={{ backgroundColor: swatch }}
title={swatch}
aria-label={swatch}
aria-pressed={selected}
onClick={() => choose(swatch)}
/>
);
})}
</div>

<div className="mt-2 flex items-center gap-2">
<label htmlFor={`${id}-hex`} className="shrink-0">
Custom
</label>
<input
id={`${id}-custom`}
type="color"
className="h-7 w-8 shrink-0 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-slate-500"
value={normalizeHexColor(color) ?? '#000000'}
title="Pick a custom color"
aria-label="Pick a custom color"
onChange={(evt) => handleHexChange(evt.target.value)}
/>
<input
id={`${id}-hex`}
type="text"
className={clsx(
'w-24 min-w-0 rounded border border-gray-200 bg-gray-100 px-2 py-1 transition',
'focus:border-primary-500 focus:ring-primary-500',
'dark:border-slate-500/80 dark:bg-slate-700 dark:text-slate-200',
!hexValid &&
'border-red-500 focus:border-red-500 focus:ring-red-500',
)}
value={hexText}
spellCheck={false}
placeholder="#rrggbb"
onChange={(evt) => handleHexChange(evt.target.value)}
/>
<button
type="button"
// not .icon-btn: that class lands after the utilities layer and would
// 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-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={reset}
>
Reset
</button>
</div>
</div>
);
};
101 changes: 66 additions & 35 deletions client/src/components/views/GraphView/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { cloneDeep } from 'lodash';
type Options = {
windowMs: number;
colors: string[];
// Layer order; the first is drawn in front and heads the key. Unlisted names
// keep arrival order, after the listed ones.
seriesOrder: string[];
// per-series color overrides; series without one fall back to `colors`
seriesColors: { [name: string]: string };
lineWidth: number;
padding: number;
keySpacing: number;
Expand All @@ -14,19 +19,14 @@ type Options = {
maxTicks: number;
};

import twColors from 'tailwindcss/colors';
import { DEFAULT_SERIES_COLORS } from './colors';

// all dimensions in this file are *CSS* pixels unless otherwise stated
export const DEFAULT_OPTIONS: Options = {
windowMs: 5000,
colors: [
twColors['blue']['600'],
twColors['red']['600'],
twColors['green']['600'],
twColors['purple']['600'],
twColors['orange']['600'],
twColors['pink']['600'],
],
colors: [...DEFAULT_SERIES_COLORS],
seriesOrder: [],
seriesColors: {},
lineWidth: 2,
padding: 15,
keySpacing: 4,
Expand Down Expand Up @@ -208,7 +208,7 @@ export default class Graph {
ctx: CanvasRenderingContext2D;
options: Options;

data: { [key: string]: { ts: number[]; vs: number[]; color: string } };
data: { [key: string]: { ts: number[]; vs: number[] } };

beginGraphNowMs = Number.NaN; // in telemetry time
beginRenderTimeMs = Number.NaN; // in browser time
Expand Down Expand Up @@ -237,15 +237,46 @@ 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
}

add(time: number, samples: Sample[][]) {
// The first name is the topmost layer.
orderedNames() {
const { seriesOrder } = this.options;

const names = Object.keys(this.data);
if (seriesOrder.length === 0) return names;

const rank = (name: string) => {
const i = seriesOrder.indexOf(name);
return i === -1 ? seriesOrder.length : i;
};

// the insertion index keeps unlisted series in a stable relative order
return names
.map((name, i) => ({ name, i }))
.sort((a, b) => rank(a.name) - rank(b.name) || a.i - b.i)
.map(({ name }) => name);
}

colorFor(name: string) {
const o = this.options;

if (Object.prototype.hasOwnProperty.call(o.seriesColors, name))
return o.seriesColors[name];

const orderIndex = o.seriesOrder.indexOf(name);
const index =
orderIndex === -1 ? Object.keys(this.data).indexOf(name) : orderIndex;

return o.colors[Math.max(index, 0) % o.colors.length];
}

add(time: number, samples: Sample[][]) {
for (const sample of samples) {
const t = sample.reduce(
(acc, { name, value }) => (name === 'time' ? value : acc),
Expand All @@ -263,7 +294,6 @@ export default class Graph {
this.data[name] = {
ts: [],
vs: [],
color: o.colors[Object.keys(this.data).length % o.colors.length],
};
}

Expand Down Expand Up @@ -352,13 +382,13 @@ export default class Graph {

this.ctx.save();

const names = Object.keys(this.data);
const names = this.orderedNames();
const numSets = names.length;
const height = numSets * o.fontSize + (numSets - 1) * o.keySpacing;
for (let i = 0; i < numSets; i++) {
const lineY = y + i * (o.fontSize + o.keySpacing) + o.fontSize / 2;
const name = names[i];
const { color } = this.data[name];
const color = this.colorFor(name);
const lineWidth =
this.ctx.measureText(name).width + o.keyLineLength + o.keySpacing;
const lineX = x + (width - lineWidth) / 2;
Expand Down Expand Up @@ -499,32 +529,33 @@ export default class Graph {

// draw data lines
// scaling is used instead of transform because of the non-uniform stretching warps the plot line
// drawn back to front so that the head of the layer order ends up on top
this.ctx.beginPath();
Object.keys(this.data).forEach((k, i) => {
const { ts, vs } = this.data[k];

if (ts.length === 0) return;
this.orderedNames()
.reverse()
.forEach((k) => {
const { ts, vs } = this.data[k];

const color = o.colors[i % o.colors.length];
if (ts.length === 0) return;

this.ctx.beginPath();
this.ctx.strokeStyle = color;
fineMoveTo(
this.ctx,
this.scaling,
scale(ts[0] - graphNowMs + o.windowMs, 0, o.windowMs, 0, width),
scale(vs[0], axis.min, axis.max, height, 0),
);
for (let j = 1; j < ts.length; j++) {
fineLineTo(
this.ctx.beginPath();
this.ctx.strokeStyle = this.colorFor(k);
fineMoveTo(
this.ctx,
this.scaling,
scale(ts[j] - graphNowMs + o.windowMs, 0, o.windowMs, 0, width),
scale(vs[j], axis.min, axis.max, height, 0),
scale(ts[0] - graphNowMs + o.windowMs, 0, o.windowMs, 0, width),
scale(vs[0], axis.min, axis.max, height, 0),
);
}
this.ctx.stroke();
});
for (let j = 1; j < ts.length; j++) {
fineLineTo(
this.ctx,
this.scaling,
scale(ts[j] - graphNowMs + o.windowMs, 0, o.windowMs, 0, width),
scale(vs[j], axis.min, axis.max, height, 0),
);
}
this.ctx.stroke();
});

this.ctx.restore();
}
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/views/GraphView/GraphCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ 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);
this.requestId = 0;
} else {
this.setState(() => ({
Expand Down
Loading
Loading