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
1 change: 1 addition & 0 deletions src/viser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ._gui_handles import GuiHtmlHandle as GuiHtmlHandle
from ._gui_handles import GuiImageHandle as GuiImageHandle
from ._gui_handles import GuiInputHandle as GuiInputHandle
from ._gui_handles import GuiLineChartHandle as GuiLineChartHandle
from ._gui_handles import GuiMarkdownHandle as GuiMarkdownHandle
from ._gui_handles import GuiMultiSliderHandle as GuiMultiSliderHandle
from ._gui_handles import GuiNumberHandle as GuiNumberHandle
Expand Down
71 changes: 71 additions & 0 deletions src/viser/_gui_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
GuiFolderHandle,
GuiHtmlHandle,
GuiImageHandle,
GuiLineChartHandle,
GuiMarkdownHandle,
GuiModalHandle,
GuiMultiSliderHandle,
Expand Down Expand Up @@ -784,6 +785,76 @@ def add_plotly(
handle.aspect = aspect
return handle

def add_mantine_linechart(
self,
x_data: Sequence[float],
y_data: Sequence[float],
title: str | None = None,
x_label: str | None = None,
y_label: str | None = None,
series_name: str = "Series 1",
color: str | None = None,
height: int = 300,
visible: bool = True,
order: float | None = None,
) -> GuiLineChartHandle:
"""Add a Mantine line chart to the GUI.

Args:
x_data: X-axis data points.
y_data: Y-axis data points.
title: Optional title for the plot.
x_label: Optional label for the x-axis.
y_label: Optional label for the y-axis.
series_name: Name for the data series.
color: Optional color for the line (CSS color string).
height: Height of the plot in pixels.
visible: Whether the plot is visible.
order: Optional ordering, smallest values will be displayed first.

Returns:
A handle that can be used to interact with the line chart.
"""
if len(x_data) != len(y_data):
raise ValueError("x_data and y_data must have the same length")

# Create data points and series to send to the client
data_points = tuple(_messages.GuiLineChartDataPoint(x=float(x), y=float(y))
for x, y in zip(x_data, y_data))
series = _messages.GuiLineChartSeries(
name=series_name,
data=data_points,
color=color
)

# this is very similar to Plotly
order = _apply_default_order(order)
message = _messages.GuiLineChartMessage(
uuid=_make_uuid(),
container_uuid=self._get_container_uuid(),
props=_messages.GuiLineChartProps(
order=order,
title=title,
x_label=x_label,
y_label=y_label,
_series_data=(series,),
height=height,
visible=visible,
),
)
self._websock_interface.queue_message(message)

return GuiLineChartHandle(
_GuiHandleState(
uuid=message.uuid,
gui_api=self,
value=None,
props=message.props,
parent_container_id=message.container_uuid,
),
_series_data=(series,),
)

def add_button(
self,
label: str,
Expand Down
42 changes: 42 additions & 0 deletions src/viser/_gui_handles.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,3 +851,45 @@ def image(self, image: np.ndarray) -> None:
)
self._data = data
del media_type


class GuiLineChartHandle(_GuiHandle[None], _messages.GuiLineChartProps):
"""Handle for updating and removing Mantine line charts."""

def __init__(self, _impl: _GuiHandleState, _series_data: tuple[_messages.GuiLineChartSeries, ...]):
super().__init__(_impl=_impl)
self._series_data_internal = _series_data

@property
def series_data(self) -> tuple[_messages.GuiLineChartSeries, ...]:
"""Current series data of this line chart. Synchronized automatically when assigned."""
return self._series_data_internal

@series_data.setter
def series_data(self, series_data: tuple[_messages.GuiLineChartSeries, ...]) -> None:
self._series_data_internal = series_data
self._series_data = series_data

def update_series(self, series_name: str, x_data: list[float], y_data: list[float], color: str | None = None) -> None:
"""Update a single data series."""
if len(x_data) != len(y_data):
raise ValueError("x_data and y_data must have the same length")

# Create new data points
new_data_points = tuple(_messages.GuiLineChartDataPoint(x=x, y=y) for x, y in zip(x_data, y_data))
new_series = _messages.GuiLineChartSeries(name=series_name, data=new_data_points, color=color)

# Update or add the series
updated_series = []
series_found = False
for series in self._series_data_internal:
if series.name == series_name:
updated_series.append(new_series)
series_found = True
else:
updated_series.append(series)

if not series_found:
updated_series.append(new_series)

self.series_data = tuple(updated_series)
39 changes: 39 additions & 0 deletions src/viser/_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,45 @@ class GuiImageMessage(_CreateGuiComponentMessage):
props: GuiImageProps


@dataclasses.dataclass
class GuiLineChartDataPoint:
"""Single data point for line chart."""
x: float
y: float


@dataclasses.dataclass
class GuiLineChartSeries:
"""Data series for line chart."""
name: str
data: Tuple[GuiLineChartDataPoint, ...]
color: Optional[str] = None


@dataclasses.dataclass
class GuiLineChartProps:
order: float
"""Order value for arranging GUI elements. Synchronized automatically when assigned."""
title: Optional[str]
"""Title of the line chart. Synchronized automatically when assigned."""
x_label: Optional[str]
"""X-axis label. Synchronized automatically when assigned."""
y_label: Optional[str]
"""Y-axis label. Synchronized automatically when assigned."""
_series_data: Tuple[GuiLineChartSeries, ...]
"""(Private) Chart data series. Synchronized automatically when assigned."""
height: int
"""Height of the chart in pixels. Synchronized automatically when assigned."""
visible: bool
"""Visibility state of the chart. Synchronized automatically when assigned."""


@dataclasses.dataclass
class GuiLineChartMessage(_CreateGuiComponentMessage):
container_uuid: str
props: GuiLineChartProps


@dataclasses.dataclass
class GuiTabGroupProps:
_tab_labels: Tuple[str, ...]
Expand Down
1 change: 1 addition & 0 deletions src/viser/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^4.0.10",
"recharts": "^2.12.7",
"react-intersection-observer": "^9.13.1",
"react-qr-code": "^2.0.12",
"rehype-color-chips": "^0.1.3",
Expand Down
3 changes: 3 additions & 0 deletions src/viser/client/src/ControlPanel/Generated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import RgbaComponent from "../components/Rgba";
import ButtonGroupComponent from "../components/ButtonGroup";
import MarkdownComponent from "../components/Markdown";
import PlotlyComponent from "../components/PlotlyComponent";
import LineChartComponent from "../components/LineChart";
import TabGroupComponent from "../components/TabGroup";
import FolderComponent from "../components/Folder";
import MultiSliderComponent from "../components/MultiSlider";
Expand Down Expand Up @@ -106,6 +107,8 @@ function GeneratedInput(props: { guiUuid: string }) {
return <HtmlComponent {...conf} />;
case "GuiPlotlyMessage":
return <PlotlyComponent {...conf} />;
case "GuiLineChartMessage":
return <LineChartComponent {...conf} />;
case "GuiImageMessage":
return <ImageComponent {...conf} />;
case "GuiButtonMessage":
Expand Down
25 changes: 25 additions & 0 deletions src/viser/client/src/WebsocketMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,28 @@ export interface GuiImageMessage {
visible: boolean;
};
}
/** GuiLineChartMessage(uuid: 'str', container_uuid: 'str', props: 'GuiLineChartProps')
*
* (automatically generated)
*/
export interface GuiLineChartMessage {
type: "GuiLineChartMessage";
uuid: string;
container_uuid: string;
props: {
order: number;
title: string | null;
x_label: string | null;
y_label: string | null;
_series_data: {
name: string;
data: { x: number; y: number }[];
color: string | null;
}[];
height: number;
visible: boolean;
};
}
/** GuiTabGroupMessage(uuid: 'str', container_uuid: 'str', props: 'GuiTabGroupProps')
*
* (automatically generated)
Expand Down Expand Up @@ -1290,6 +1312,7 @@ export type Message =
| GuiProgressBarMessage
| GuiPlotlyMessage
| GuiImageMessage
| GuiLineChartMessage
| GuiTabGroupMessage
| GuiButtonMessage
| GuiUploadButtonMessage
Expand Down Expand Up @@ -1376,6 +1399,7 @@ export type GuiComponentMessage =
| GuiProgressBarMessage
| GuiPlotlyMessage
| GuiImageMessage
| GuiLineChartMessage
| GuiTabGroupMessage
| GuiButtonMessage
| GuiUploadButtonMessage
Expand Down Expand Up @@ -1428,6 +1452,7 @@ const typeSetGuiComponentMessage = new Set([
"GuiProgressBarMessage",
"GuiPlotlyMessage",
"GuiImageMessage",
"GuiLineChartMessage",
"GuiTabGroupMessage",
"GuiButtonMessage",
"GuiUploadButtonMessage",
Expand Down
104 changes: 104 additions & 0 deletions src/viser/client/src/components/LineChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from "react";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Box, Paper, Text } from "@mantine/core";
import { GuiLineChartMessage } from "../WebsocketMessages";
import { folderWrapper } from "./Folder.css";

interface DataPoint {
x: number;
y: number;
[key: string]: number;
}

function processSeriesData(seriesData: GuiLineChartMessage["props"]["_series_data"]): DataPoint[] {
if (seriesData.length === 0) return [];

// Create a map of x values to data points
const xValueMap = new Map<number, DataPoint>();

// Populate the map with all x values and their corresponding y values for each series
seriesData.forEach((series) => {
series.data.forEach((point) => {
if (!xValueMap.has(point.x)) {
xValueMap.set(point.x, { x: point.x, y: 0 });
}
const dataPoint = xValueMap.get(point.x)!;
dataPoint[series.name] = point.y;
});
});

// Convert map to array and sort by x value
return Array.from(xValueMap.values()).sort((a, b) => a.x - b.x);
}

export default function LineChartComponent({
props: { visible, title, x_label, y_label, _series_data, height },
}: GuiLineChartMessage) {
if (!visible) return <></>;

const data = processSeriesData(_series_data);

// Generate colors for series that don't have explicit colors
const getColor = (index: number, customColor?: string | null) => {
if (customColor) return customColor;

const colors = [
"#8884d8", "#82ca9d", "#ffc658", "#ff7c7c", "#8dd1e1",
"#d084d0", "#ffb347", "#87ceeb", "#98fb98", "#f0e68c"
];
return colors[index % colors.length];
};

return (
<Paper className={folderWrapper} withBorder style={{ padding: "12px" }}>
{title && (
<Text size="sm" weight={600} align="center" mb="xs">
{title}
</Text>
)}
<Box style={{ width: "100%", height: height }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="x"
stroke="#666"
fontSize={12}
label={x_label ? { value: x_label, position: "insideBottom", offset: -5 } : undefined}
/>
<YAxis
stroke="#666"
fontSize={12}
label={y_label ? { value: y_label, angle: -90, position: "insideLeft" } : undefined}
/>
<Tooltip
contentStyle={{
backgroundColor: "#fff",
border: "1px solid #ccc",
borderRadius: "4px",
fontSize: "12px"
}}
/>
{_series_data.length > 1 && (
<Legend
wrapperStyle={{ fontSize: "12px" }}
/>
)}
{_series_data.map((series, index) => (
<Line
key={series.name}
type="monotone"
dataKey={series.name}
stroke={getColor(index, series.color)}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 4 }}
connectNulls={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</Box>
</Paper>
);
}
Loading