diff --git a/dashi/src/App.tsx b/dashi/src/App.tsx index 452dde29..e1c4160e 100644 --- a/dashi/src/App.tsx +++ b/dashi/src/App.tsx @@ -1,51 +1,106 @@ import { ReactElement, useEffect, useState } from "react"; import "./App.css"; -import { EventHandler, PanelModel } from "./lib/model.ts"; -import { fetchPanelInit, fetchPanelUpdate, FetchResponse } from "./api.ts"; +import { PanelEventHandler, PanelModel, Panels } from "./lib/model.ts"; +import { fetchPanels, fetchPanel, FetchResponse } from "./api.ts"; import DashiPanel from "./lib/DashiPanel.tsx"; function App() { - const [panelModelResponse, setPanelModelResponse] = useState< - FetchResponse + const [panelsResponse, setPanelsResponse] = useState>( + {}, + ); + + const [panelResponses, setPanelResponses] = useState< + Record> + >({}); + + const [panelVisibilities, setPanelVisibilities] = useState< + Record >({}); useEffect(() => { - fetchPanelInit().then( - (panelModelResponse) => void setPanelModelResponse(panelModelResponse), - ); + fetchPanels().then(setPanelsResponse); }, []); - const handleEvent: EventHandler = (event) => { - fetchPanelUpdate(event).then( - (panelModelResponse) => void setPanelModelResponse(panelModelResponse), - ); + useEffect(() => { + Object.getOwnPropertyNames(panelVisibilities).forEach((panelId) => { + const panelVisible = panelVisibilities[panelId]; + const panelResponse = panelResponses[panelId]; + if (panelVisible && !panelResponse) { + fetchPanel(panelId).then((panelResponse) => { + setPanelResponses({ ...panelResponses, [panelId]: panelResponse }); + }); + } + }); + }, [panelVisibilities, panelResponses]); + + const handlePanelEvent: PanelEventHandler = (event) => { + fetchPanel(event.panelId, event).then((result) => { + setPanelResponses({ ...panelResponses, [event.panelId]: result }); + }); }; - console.info("panelModelResponse:", panelModelResponse); - - const { result, error } = panelModelResponse; - let panelComponent: ReactElement | undefined; - if (result) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { type, ...panelProps } = result; - panelComponent = ( - + let panelSelector: ReactElement; + if (panelsResponse.result) { + const panelIds = panelsResponse.result.panels; + panelSelector = ( +
+ {panelIds.map((panelId) => ( +
+ { + setPanelVisibilities({ + ...panelVisibilities, + [panelId]: e.currentTarget.checked, + }); + }} + /> + +
+ ))} +
); - } else if (error) { - panelComponent =
Error: {error}
; + } else if (panelsResponse.error) { + panelSelector =
Error: {panelsResponse.error}
; } else { - panelComponent =
Loading chart...
; + panelSelector =
Loading panels...
; } + const panelComponents: ReactElement[] = []; + Object.getOwnPropertyNames(panelVisibilities).forEach((panelId) => { + const panelVisible = panelVisibilities[panelId]; + const panelResponse = panelResponses[panelId]; + let panelComponent: ReactElement; + if (panelVisible && panelResponse) { + if (panelResponse.result) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type, ...panelProps } = panelResponse.result as PanelModel; + panelComponent = ( + + ); + } else if (panelResponse.error) { + panelComponent =
Error: {panelResponse.error}
; + } else { + panelComponent =
Loading chart...
; + } + panelComponents.push(panelComponent); + } + }); + return ( <>

Dashi Demo

- {panelComponent} + {panelSelector} +
{panelComponents}
); } diff --git a/dashi/src/api.ts b/dashi/src/api.ts index 95c804d8..2d2de9ec 100644 --- a/dashi/src/api.ts +++ b/dashi/src/api.ts @@ -1,24 +1,27 @@ -import { EventData, PanelModel } from "./lib/model.ts"; +import { PanelEventData, PanelModel, Panels } from "./lib/model.ts"; export interface FetchResponse { result?: T; error?: string; } -const API_URL = `http://localhost:8888/panel`; - -export async function fetchPanelInit(): Promise> { - return fetchJson(API_URL); +export async function fetchPanels(): Promise> { + return fetchJson("http://localhost:8888/panels"); } -export async function fetchPanelUpdate( - event: EventData, +export async function fetchPanel( + panelId: string, + event?: PanelEventData, ): Promise> { - return fetchJson(API_URL, { - method: "post", - headers: {}, - body: JSON.stringify(event), - }); + const url = `http://localhost:8888/panels/${panelId}`; + if (event) { + return fetchJson(url, { + method: "post", + body: JSON.stringify(event), + }); + } else { + return fetchJson(url); + } } export async function fetchJson( diff --git a/dashi/src/lib/DashiPanel.tsx b/dashi/src/lib/DashiPanel.tsx index 0767c57c..5119f749 100644 --- a/dashi/src/lib/DashiPanel.tsx +++ b/dashi/src/lib/DashiPanel.tsx @@ -1,13 +1,15 @@ -import { EventHandler, makeId, PanelModel } from "./model"; +import { PanelEventHandler, makeId, PanelModel, EventData } from "./model"; import DashiContainer from "./DashiContainer"; export interface DashiPanelProps extends Omit { + panelId: string; width: number; height: number; - onEvent: EventHandler; + onEvent: PanelEventHandler; } function DashiPanel({ + panelId, width, height, id, @@ -15,9 +17,12 @@ function DashiPanel({ components, onEvent, }: DashiPanelProps) { + const handleEvent = (event: EventData) => { + onEvent({ panelId, ...event }); + }; return (
- +
); } diff --git a/dashi/src/lib/model.ts b/dashi/src/lib/model.ts index 9b33d3da..b1653a43 100644 --- a/dashi/src/lib/model.ts +++ b/dashi/src/lib/model.ts @@ -32,12 +32,16 @@ export interface PlotModel extends ComponentModel { config: Partial; } +export interface BoxModel extends ContainerModel { + type: "box"; +} + export interface PanelModel extends ContainerModel { type: "panel"; } -export interface BoxModel extends ContainerModel { - type: "box"; +export interface Panels { + panels: string[]; } export interface EventData { @@ -47,7 +51,12 @@ export interface EventData { eventData?: T; } +export interface PanelEventData extends EventData { + panelId: string; +} + export type EventHandler = (data: EventData) => void; +export type PanelEventHandler = (data: PanelEventData) => void; export function makeId(type: ComponentType, id: string | undefined) { if (typeof id === "string" && id !== "") { diff --git a/dashipy/dashipy/lib/plot.py b/dashipy/dashipy/lib/plot.py index a3ab32c0..f1e790c3 100644 --- a/dashipy/dashipy/lib/plot.py +++ b/dashipy/dashipy/lib/plot.py @@ -1,3 +1,4 @@ +import json from typing import Any import plotly.graph_objects as go @@ -20,5 +21,9 @@ def __init__( def to_dict(self) -> dict[str, Any]: return { **super().to_dict(), - **self.figure.to_dict(), + # TODO: this is stupid, but if using self.figure.to_dict() + # for plotly.express figures we get + # TypeError: Object of type ndarray is not JSON serializable + **json.loads(self.figure.to_json()), + # **self.figure.to_dict(), } diff --git a/dashipy/dashipy/server.py b/dashipy/dashipy/server.py index ea2edf0e..46b841ed 100644 --- a/dashipy/dashipy/server.py +++ b/dashipy/dashipy/server.py @@ -1,21 +1,17 @@ import asyncio +import importlib +from typing import Callable, Any import tornado import tornado.web import tornado.log +import yaml from dashipy.context import Context -from dashipy.panel import get_panel from dashipy import __version__ -class RootHandler(tornado.web.RequestHandler): - def get(self): - self.set_header("Content-Type", "text/plain") - self.write(f"dashi-server {__version__}") - - -class PanelHandler(tornado.web.RequestHandler): +class RequestHandler(tornado.web.RequestHandler): def set_default_headers(self): self.set_header("Access-Control-Allow-Origin", "*") @@ -26,30 +22,74 @@ def set_default_headers(self): "authorization,content-type", ) + +class RootHandler(RequestHandler): def get(self): - context: Context = self.settings["context"] - panel = get_panel(context) + self.set_header("Content-Type", "text/plain") + self.write(f"dashi-server {__version__}") + + +class PanelsHandler(RequestHandler): + + # GET /panels + def get(self): + panels: dict[str, Callable] = self.settings["panels"] self.set_header("Content-Type", "text/json") - self.write(panel.to_dict()) + self.write({"panels": list(panels.keys())}) - def post(self): - context: Context = self.settings["context"] + +class PanelRendererHandler(RequestHandler): + # GET /panels/{panel_id} + def get(self, panel_id: str): + self.render_panel(panel_id, {}) + + # POST /panels/{panel_id} + def post(self, panel_id: str): event = tornado.escape.json_decode(self.request.body) - print(event) - event_data = event.get("eventData") or {} - panel = get_panel(context, **event_data) - self.set_header("Content-Type", "text/json") - self.write(panel.to_dict()) + panel_props = event.get("eventData") or {} + self.render_panel(panel_id, panel_props) + + def render_panel(self, panel_id: str, panel_props: dict[str, Any]): + context: Context = self.settings["context"] + panels: dict[str, Callable] = self.settings["panels"] + panel_renderer = panels.get(panel_id) + if panel_renderer is not None: + self.set_header("Content-Type", "text/json") + self.write(panel_renderer(context, **panel_props).to_dict()) + else: + self.set_status(404, f"panel not found: {panel_props}") def make_app(): + # Read config + with open("my-config.yaml") as f: + server_config = yaml.load(f, yaml.SafeLoader) + + # Parse panel renderers + panels: dict[str, Callable] = {} + for panel_ref in server_config.get("panels", []): + try: + module_name, function_name = panel_ref.rsplit(".", maxsplit=2) + except (ValueError, AttributeError): + raise TypeError(f"panel renderer syntax error: {panel_ref!r}") + module = importlib.import_module(module_name) + render_function = getattr(module, function_name) + if not callable(render_function): + raise TypeError( + f"panel renderer {panel_ref!r} does not refer to a callable" + ) + panels[panel_ref.replace(".", "-").replace("_", "-").lower()] = render_function + + # Create app app = tornado.web.Application( [ (r"/", RootHandler), - (r"/panel", PanelHandler), + (r"/panels", PanelsHandler), + (r"/panels/([a-z0-9-]+)", PanelRendererHandler), ] ) app.settings["context"] = Context() + app.settings["panels"] = panels return app @@ -58,7 +98,7 @@ async def main(): port = 8888 app = make_app() app.listen(port) - print(f"Listening http://127.0.0.1:{port}...") + print(f"Listening on http://127.0.0.1:{port}...") shutdown_event = asyncio.Event() await shutdown_event.wait() diff --git a/dashipy/environment.yml b/dashipy/environment.yml index 1ffa8534..46500ced 100644 --- a/dashipy/environment.yml +++ b/dashipy/environment.yml @@ -4,6 +4,7 @@ channels: dependencies: - python # Dependencies + - pandas - plotly - pyaml - tornado diff --git a/dashipy/my-config.yaml b/dashipy/my-config.yaml new file mode 100644 index 00000000..82700bb1 --- /dev/null +++ b/dashipy/my-config.yaml @@ -0,0 +1,3 @@ +panels: + - my_panels.render_panel_1 + - my_panels.render_panel_2 diff --git a/dashipy/dashipy/panel.py b/dashipy/my_panels.py similarity index 54% rename from dashipy/dashipy/panel.py rename to dashipy/my_panels.py index f10e1276..2a8e3341 100644 --- a/dashipy/dashipy/panel.py +++ b/dashipy/my_panels.py @@ -1,3 +1,4 @@ +import plotly.express as pe import plotly.graph_objects as go from plotly.graph_objs import Layout @@ -5,7 +6,7 @@ from dashipy.context import Context -def get_panel( +def render_panel_1( context: Context, selected_dataset: int = 0, ) -> Panel: @@ -34,3 +35,34 @@ def get_panel( style={"display": "flex", "flexDirection": "column"}, components=[plot, button_group], ) + + +def render_panel_2( + context: Context, + selected_dataset: int = 0, +) -> Panel: + dataset = context.datasets[selected_dataset] + + plot = Plot( + pe.line(x=[-1.0, 0.0, 1.0], y=dataset, title=f"DS #{selected_dataset + 1}") + ) + + buttons = [ + Button(text=f"DS #{i + 1}", name="selected_dataset", value=i) + for i in range(len(context.datasets)) + ] + button_group = Box( + style={ + "display": "flex", + "flexDirection": "row", + "padding": 4, + "justifyContent": "center", + "gap": 4, + }, + components=buttons, + ) + + return Panel( + style={"display": "flex", "flexDirection": "column"}, + components=[plot, button_group], + ) diff --git a/dashipy/pyproject.toml b/dashipy/pyproject.toml index 04acdc59..e71dadf3 100644 --- a/dashipy/pyproject.toml +++ b/dashipy/pyproject.toml @@ -18,7 +18,10 @@ keywords = [ license = {text = "MIT"} requires-python = ">=3.10" dependencies = [ - "tornado", + "pandas", + "plotly", + "pyaml", + "tornado" ] classifiers = [ "Development Status :: 5 - Production/Stable",