diff --git a/DashAI/alembic/versions/f7a4c2e91b60_add_plot_overrides_to_explorer.py b/DashAI/alembic/versions/f7a4c2e91b60_add_plot_overrides_to_explorer.py new file mode 100644 index 000000000..9a23a8125 --- /dev/null +++ b/DashAI/alembic/versions/f7a4c2e91b60_add_plot_overrides_to_explorer.py @@ -0,0 +1,26 @@ +"""Add plot_overrides to explorer + +Revision ID: f7a4c2e91b60 +Revises: d5b3c8f2a041 +Create Date: 2026-08-11 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "f7a4c2e91b60" +down_revision: Union[str, None] = "d5b3c8f2a041" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("explorer", sa.Column("plot_overrides", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("explorer", "plot_overrides") diff --git a/DashAI/back/api/api_v1/endpoints/explainers.py b/DashAI/back/api/api_v1/endpoints/explainers.py index 617afa2bd..46e941d45 100755 --- a/DashAI/back/api/api_v1/endpoints/explainers.py +++ b/DashAI/back/api/api_v1/endpoints/explainers.py @@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, status from fastapi.exceptions import HTTPException from kink import di, inject -from pydantic import BaseModel from sqlalchemy import exc, select from DashAI.back.api.api_v1.schemas.explainers_params import ( @@ -13,6 +12,7 @@ ValidateDatasetParams, ValidDatasetsParams, ) +from DashAI.back.core.artifacts import PlotOverrideBody, apply_plot_overrides from DashAI.back.core.enums.status import ExplainerStatus from DashAI.back.dependencies.database.models import ( Dataset, @@ -31,73 +31,6 @@ router = APIRouter() -def _apply_overrides(artifacts: list, overrides: dict | None) -> list: - """Replace plotly artifact payloads with stored edited figures. - - Leaves nested inside a ``"grouped"`` selector (see - :class:`DashAI.back.core.artifacts.GroupedArtifacts`), i.e. under each - group's ``artifacts``, are matched by their stamped ``"index"`` just - like top level ones, so a group's plotly artifact can be edited/reset the - same way as a top level one. - - Parameters - ---------- - artifacts : list - Normalized artifact/grouped dicts from ``normalize_artifacts``. - overrides : dict or None - Mapping of ``str(index)`` to an edited plotly figure (JSON string). - - Returns - ------- - list - The artifacts with overridden plotly payloads applied. - """ - if not overrides: - return artifacts - import json - - leaves_by_index = {} - - def collect_leaves(items): - for item in items: - if item.get("type") == "grouped": - for group in item.get("groups", []): - collect_leaves(group.get("artifacts", [])) - else: - leaves_by_index[item.get("index")] = item - - collect_leaves(artifacts) - - for key, figure in overrides.items(): - try: - idx = int(key) - except (TypeError, ValueError): - continue - leaf = leaves_by_index.get(idx) - if leaf is not None and leaf.get("type") == "plotly": - leaf["payload"] = figure if isinstance(figure, str) else json.dumps(figure) - # Flag so the frontend renders the user's edited figure verbatim - # instead of re-applying the app theme (which would clobber the - # edited colors/background). - leaf["overridden"] = True - return artifacts - - -class PlotOverrideBody(BaseModel): - """Request body for saving one plot override. - - Parameters - ---------- - index : int - Artifact index whose payload is being overridden. - figure : object - The edited plotly figure, either a JSON string or a dict. - """ - - index: int - figure: object - - @router.get("/global") @inject async def get_global_explainers( @@ -271,7 +204,7 @@ async def get_global_explanation_plot( detail="Internal database error", ) from e - return _apply_overrides(normalize_artifacts(plot), plot_overrides) + return apply_plot_overrides(normalize_artifacts(plot), plot_overrides) @router.post("/global", status_code=status.HTTP_201_CREATED) @@ -555,7 +488,7 @@ async def get_local_explanation_plot( detail="Internal database error", ) from e - return _apply_overrides( + return apply_plot_overrides( normalize_artifacts(plots, create_grouped=True), plot_overrides ) diff --git a/DashAI/back/api/api_v1/endpoints/explorers.py b/DashAI/back/api/api_v1/endpoints/explorers.py index 7790e2df5..94319ff05 100644 --- a/DashAI/back/api/api_v1/endpoints/explorers.py +++ b/DashAI/back/api/api_v1/endpoints/explorers.py @@ -12,6 +12,7 @@ ExplorerCreate, ExplorerResultsOptions, ) +from DashAI.back.core.artifacts import PlotOverrideBody, apply_plot_overrides from DashAI.back.core.enums.status import ExplorerStatus from DashAI.back.dependencies.database.models import Dataset, Explorer, Notebook @@ -368,6 +369,9 @@ async def get_explorer_results( component_registry=component_registry, session=db, ) + # Stored user edits win over the computed figure, and are flagged so + # the frontend renders them verbatim instead of re-theming them. + artifacts = apply_plot_overrides(artifacts, explorer_info.plot_overrides) return artifacts @@ -375,50 +379,98 @@ async def get_explorer_results( @router.put("/{explorer_id}/results/") @inject async def update_explorer_results( - params: dict, explorer_id: int, + body: PlotOverrideBody, session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]), - component_registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]), ): - """Store the plot edits (data and layout) made from the frontend. + """Persist an edited plotly figure for one artifact of an exploration. + + The edit is stored on the explorer record keyed by artifact index. The + stored artifacts file is left untouched, so the computed figure survives + and ``delete_explorer_results_override`` can restore it. - The edited figure replaces the payload of the first stored artifact; the - raw exploration file is left untouched as the original result. + Parameters + ---------- + explorer_id : int + Id of the explorer whose plot is being edited. + body : PlotOverrideBody + The artifact index and the edited plotly figure. + session_factory : Callable[..., ContextManager[Session]] + Factory yielding a SQLAlchemy session. + + Returns + ------- + dict + A confirmation message. + + Raises + ------ + HTTPException + 404 if the explorer does not exist. """ import json - from DashAI.back.exploration.artifact_store import write_artifacts - db: "Session" with session_factory() as db: - explorer = db.query(Explorer).get(explorer_id) + explorer = db.get(Explorer, explorer_id) if explorer is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Explorer not found", ) - artifacts = load_explorer_artifacts( - explorer=explorer, - component_registry=component_registry, - session=db, + overrides = dict(explorer.plot_overrides or {}) + figure = body.figure + overrides[str(body.index)] = ( + figure if isinstance(figure, str) else json.dumps(figure) ) + explorer.plot_overrides = overrides + db.commit() + + return {"message": "Explorer results updated successfully"} + + +@router.delete("/{explorer_id}/results/override/{index}") +@inject +async def delete_explorer_results_override( + explorer_id: int, + index: int, + session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]), +): + """Remove a stored plot override, reverting to the computed figure. + + Parameters + ---------- + explorer_id : int + Id of the explorer. + index : int + Artifact index whose override is removed. Removing an index that has + no override is a no-op. + session_factory : Callable[..., ContextManager[Session]] + Factory yielding a SQLAlchemy session. + + Returns + ------- + dict + A confirmation message. - if not artifacts: + Raises + ------ + HTTPException + 404 if the explorer does not exist. + """ + db: "Session" + with session_factory() as db: + explorer = db.get(Explorer, explorer_id) + if explorer is None: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Explorer has no results to update", + status_code=status.HTTP_404_NOT_FOUND, + detail="Explorer not found", ) - # update results - try: - artifacts[0] = {**artifacts[0], "payload": json.dumps(params)} - write_artifacts(explorer.artifacts_path, artifacts) - except Exception as e: - log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Error while updating explorer results", - ) from e + overrides = dict(explorer.plot_overrides or {}) + overrides.pop(str(index), None) + explorer.plot_overrides = overrides or None + db.commit() - return {"message": "Explorer results updated successfully"} + return {"message": "Explorer results restored successfully"} diff --git a/DashAI/back/core/artifacts.py b/DashAI/back/core/artifacts.py index 04d423d49..f0700bff2 100644 --- a/DashAI/back/core/artifacts.py +++ b/DashAI/back/core/artifacts.py @@ -30,6 +30,7 @@ """ import base64 +import json from typing import Annotated, Any, Dict, List, Literal, Optional, Union from pydantic import ( @@ -554,3 +555,72 @@ def build_image_input_artifact( artifact = ImageArtifact.from_dashai_image(image, title=title) artifact.role = "input" return artifact + + +class PlotOverrideBody(BaseModel): + """Request body for saving one plot override. + + Parameters + ---------- + index : int + Artifact index whose payload is being overridden. + figure : object + The edited plotly figure, either a JSON string or a dict. + """ + + index: int + figure: object + + +def apply_plot_overrides( + artifacts: List[Dict[str, Any]], overrides: Optional[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """Replace plotly artifact payloads with stored edited figures. + + Leaves nested inside a ``"grouped"`` selector (see + :class:`GroupedArtifacts`), i.e. under each group's ``artifacts``, are + matched by their stamped ``"index"`` just like top level ones, so a + group's plotly artifact can be edited/reset the same way as a top level + one. + + Parameters + ---------- + artifacts : List[Dict[str, Any]] + Normalized artifact/grouped dicts from :func:`normalize_artifacts`. + overrides : Optional[Dict[str, Any]] + Mapping of ``str(index)`` to an edited plotly figure (JSON string or + dict). + + Returns + ------- + List[Dict[str, Any]] + The artifacts with overridden plotly payloads applied. + """ + if not overrides: + return artifacts + + leaves_by_index: Dict[Any, Dict[str, Any]] = {} + + def collect_leaves(items: List[Dict[str, Any]]) -> None: + for item in items: + if item.get("type") == "grouped": + for group in item.get("groups", []): + collect_leaves(group.get("artifacts", [])) + else: + leaves_by_index[item.get("index")] = item + + collect_leaves(artifacts) + + for key, figure in overrides.items(): + try: + idx = int(key) + except (TypeError, ValueError): + continue + leaf = leaves_by_index.get(idx) + if leaf is not None and leaf.get("type") == "plotly": + leaf["payload"] = figure if isinstance(figure, str) else json.dumps(figure) + # Flag so the frontend renders the user's edited figure verbatim + # instead of re-applying the app theme (which would clobber the + # edited colors/background). + leaf["overridden"] = True + return artifacts diff --git a/DashAI/back/dependencies/database/models.py b/DashAI/back/dependencies/database/models.py index 761c76f95..d851fcc83 100644 --- a/DashAI/back/dependencies/database/models.py +++ b/DashAI/back/dependencies/database/models.py @@ -702,6 +702,10 @@ class Explorer(Base): # Render artifacts built once, when the exploration is created, so results # keep rendering after the explorer class is removed from the registry. artifacts_path: Mapped[str] = mapped_column(String, nullable=True) + # Per artifact index -> edited plotly figure, applied over the stored + # artifacts on read. Kept apart from artifacts_path so the computed figure + # survives an edit and a reset can restore it. + plot_overrides: Mapped[JSON] = mapped_column(JSON, nullable=True) # Metadata name: Mapped[str] = mapped_column(String, nullable=True) diff --git a/DashAI/front/src/api/__mocks__/api.ts b/DashAI/front/src/api/__mocks__/api.ts index 22bcb5e06..39a52c49e 100644 --- a/DashAI/front/src/api/__mocks__/api.ts +++ b/DashAI/front/src/api/__mocks__/api.ts @@ -1,5 +1,6 @@ export default { post: jest.fn(), get: jest.fn(), + put: jest.fn(), delete: jest.fn(), }; diff --git a/DashAI/front/src/api/explorer.test.ts b/DashAI/front/src/api/explorer.test.ts new file mode 100644 index 000000000..7a4f6c53c --- /dev/null +++ b/DashAI/front/src/api/explorer.test.ts @@ -0,0 +1,34 @@ +jest.mock("./api"); + +import api from "./api"; +import { resetExplorerResults, updateExplorerResults } from "./explorer"; + +describe("explorer plot override api", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("puts the artifact index alongside the edited figure", async () => { + (api.put as jest.Mock).mockResolvedValue({ data: { message: "ok" } }); + const figure = { data: [], layout: { title: "edited" } }; + + const result = await updateExplorerResults(7, 2, figure); + + expect(api.put).toHaveBeenCalledWith("/v1/explorer/7/results/", { + index: 2, + figure, + }); + expect(result).toEqual({ message: "ok" }); + }); + + it("deletes the override for one artifact index", async () => { + (api.delete as jest.Mock).mockResolvedValue({ data: { message: "ok" } }); + + const result = await resetExplorerResults(7, 2); + + expect(api.delete).toHaveBeenCalledWith( + "/v1/explorer/7/results/override/2", + ); + expect(result).toEqual({ message: "ok" }); + }); +}); diff --git a/DashAI/front/src/api/explorer.ts b/DashAI/front/src/api/explorer.ts index 9f5561083..14931d3d1 100644 --- a/DashAI/front/src/api/explorer.ts +++ b/DashAI/front/src/api/explorer.ts @@ -115,11 +115,22 @@ export const getExplorerResults = async ( export const updateExplorerResults = async ( explorerId: number, - data: object, + index: number, + figure: unknown, ): Promise<{ message: string }> => { - const response = await api.put( - `${explorerEndpoint}/${explorerId}/results/`, - data, + const response = await api.put(`${explorerEndpoint}/${explorerId}/results/`, { + index, + figure, + }); + return response.data; +}; + +export const resetExplorerResults = async ( + explorerId: number, + index: number, +): Promise<{ message: string }> => { + const response = await api.delete( + `${explorerEndpoint}/${explorerId}/results/override/${index}`, ); return response.data; }; diff --git a/DashAI/front/src/components/notebooks/converter/ConverterBox.jsx b/DashAI/front/src/components/notebooks/converter/ConverterBox.jsx index 13e64d71b..e47461c77 100644 --- a/DashAI/front/src/components/notebooks/converter/ConverterBox.jsx +++ b/DashAI/front/src/components/notebooks/converter/ConverterBox.jsx @@ -1,10 +1,9 @@ import React, { useState, useEffect } from "react"; import { - Card, - CardContent, + Paper, Box, Typography, - Chip, + Tooltip, CircularProgress, IconButton, } from "@mui/material"; @@ -15,6 +14,7 @@ import { useMaterialReactTable, } from "material-react-table"; import Transform from "@mui/icons-material/Transform"; +import RunStatusDot from "../../shared/RunStatusDot"; import { getConverterStatus } from "../../../utils/converterStatus"; import { getComponentById } from "../../../api/component"; import { getConverterById } from "../../../api/converter"; @@ -126,12 +126,15 @@ export default function ConverterBox({ const statusLabel = converter.status; return ( - - {converterComponent.display_name} + + + + + - - + {(statusLabel === 4 || statusLabel === 3) && ( // Error or Finished handleConverterDeleteClick(converter)} - sx={{ - width: 24, - height: 24, - bgcolor: "error.main", - "&:hover": { bgcolor: "error.dark" }, - }} > - + )} @@ -260,7 +258,7 @@ export default function ConverterBox({ {t("common:processing")} )} - - + + ); } diff --git a/DashAI/front/src/components/notebooks/explorer/ExplorerBox.jsx b/DashAI/front/src/components/notebooks/explorer/ExplorerBox.jsx index 75dfafe28..21dc6eacb 100644 --- a/DashAI/front/src/components/notebooks/explorer/ExplorerBox.jsx +++ b/DashAI/front/src/components/notebooks/explorer/ExplorerBox.jsx @@ -1,24 +1,32 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { - Card, - CardContent, + Paper, Box, Typography, - Chip, + Tooltip, IconButton, CircularProgress, - Button, } from "@mui/material"; import { useTheme, alpha } from "@mui/material/styles"; import { Analytics, Info, Delete } from "@mui/icons-material"; -import { TabResults } from "./tabs"; +import { useSnackbar } from "notistack"; +import RunStatusDot from "../../shared/RunStatusDot"; +import ArtifactViewer from "../../shared/ArtifactViewer"; import { getExplorerStatus } from "../../../utils/explorerStatus"; import { getComponentById } from "../../../api/component"; -import { getExplorerById } from "../../../api/explorer"; -import ExplorerDetailsModal from "../explorer/ExplorerDetailsModal"; +import { + getExplorerById, + resetExplorerResults, + updateExplorerResults, +} from "../../../api/explorer"; +import ExplorerInfoModal from "./ExplorerInfoModal"; import { useExplorerResults } from "./useExplorerResults"; import { useTranslation } from "react-i18next"; +// Floor for the measured plot height, so a short card degrades to a scrollable +// plot rather than a squashed, unreadable one. +const MIN_PLOT_HEIGHT = 160; + export default function ExplorerBox({ explorer, handleExplorerDeleteClick, @@ -26,18 +34,80 @@ export default function ExplorerBox({ isHighlighted = false, }) { const { t } = useTranslation(["datasets", "common"]); + const { enqueueSnackbar } = useSnackbar(); const theme = useTheme(); const [explorerComponent, setExplorerComponent] = useState({}); const [openExplorerDetails, setOpenExplorerDetails] = useState(false); - const { loading, data, dataType, setData, error } = + const { loading, artifact, error, fetchExplorerResults } = useExplorerResults(explorer); const statusLabel = explorer.status; + // The card sits in a fixed height grid cell, so the figure cannot use the + // renderer's default height without overflowing. Measure the space the body + // actually gets and hand that down, minus the padded, bordered block + // ArtifactViewer draws around the plot. + const bodyRef = useRef(null); + const [plotHeight, setPlotHeight] = useState(null); + + useEffect(() => { + const element = bodyRef.current; + if (!element || typeof ResizeObserver === "undefined") return undefined; + + const chrome = parseFloat(theme.spacing(3)) * 2 + 2; + const observer = new ResizeObserver(([entry]) => { + const available = entry.contentRect.height - chrome; + setPlotHeight(Math.max(MIN_PLOT_HEIGHT, Math.round(available))); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [theme, statusLabel]); + const handleExplorerDetailsClick = () => { setOpenExplorerDetails(true); }; + const handleSaveEdit = useCallback( + async (figure) => { + try { + await updateExplorerResults(explorer.id, artifact.index, figure); + // Pull the artifact back with its `overridden` flag set, so the reset + // button shows up straight away instead of only after a reopen. + await fetchExplorerResults(); + enqueueSnackbar( + t("datasets:message.explorerResultsUpdatedSuccessfully"), + { variant: "success" }, + ); + } catch (err) { + console.error("Failed to update explorer results:", err); + enqueueSnackbar(t("datasets:error.failedToUpdateExplorerResults"), { + variant: "error", + }); + // Rethrow so ArtifactViewer does not treat the edit as saved. + throw err; + } + }, + [explorer.id, artifact, fetchExplorerResults, enqueueSnackbar, t], + ); + + const handleResetEdit = useCallback(async () => { + try { + await resetExplorerResults(explorer.id, artifact.index); + // The stored artifact changed on the server, so pull the computed + // figure back in rather than guessing it client side. + await fetchExplorerResults(); + enqueueSnackbar( + t("datasets:message.explorerResultsRestoredSuccessfully"), + { variant: "success" }, + ); + } catch (err) { + console.error("Failed to reset explorer results:", err); + enqueueSnackbar(t("datasets:error.failedToResetExplorerResults"), { + variant: "error", + }); + } + }, [explorer.id, artifact, fetchExplorerResults, enqueueSnackbar, t]); + useEffect(() => { const fetchConverterComponent = async () => { try { @@ -84,11 +154,15 @@ export default function ExplorerBox({ }, [explorer.id, explorer.status, onStatusChange]); return ( - - @@ -133,74 +207,77 @@ export default function ExplorerBox({ explorer.exploration_type ?? t("datasets:unknownComponent")} - - - - <> - {statusLabel === 3 && ( // Finished - + + handleExplorerDetailsClick(explorer)} - size="small" - color="primary" - icon={} - sx={{ - "&:hover": { bgcolor: "secondary.main" }, - }} /> - )} - {(statusLabel === 4 || statusLabel === 3) && ( // Error or Finished + + + + + {statusLabel === 3 && ( // Finished + handleExplorerDeleteClick(explorer)} - sx={{ - width: 24, - height: 24, - bgcolor: "error.main", - "&:hover": { bgcolor: "error.dark" }, - }} + aria-label="info" + onClick={handleExplorerDetailsClick} > - + - )} - + + )} + {(statusLabel === 4 || statusLabel === 3) && ( // Error or Finished + handleExplorerDeleteClick(explorer)} + > + + + )} {statusLabel === 3 ? ( // Finished ) : statusLabel === 4 ? ( // Error )} {openExplorerDetails && ( - { setOpenExplorerDetails(false); }} explorer={explorer} explorerComponent={explorerComponent} - data={data} - dataType={dataType} - loading={loading} - setData={setData} - error={error} /> )} - - + + ); } diff --git a/DashAI/front/src/components/notebooks/explorer/ExplorerDetailsModal.jsx b/DashAI/front/src/components/notebooks/explorer/ExplorerDetailsModal.jsx deleted file mode 100644 index 198c01c7f..000000000 --- a/DashAI/front/src/components/notebooks/explorer/ExplorerDetailsModal.jsx +++ /dev/null @@ -1,291 +0,0 @@ -import React, { - useState, - useEffect, - useCallback, - useRef, - startTransition, -} from "react"; -import { - Tabs, - Tab, - Box, - Dialog, - DialogTitle, - DialogContent, - IconButton, - Typography, - Divider, - CircularProgress, -} from "@mui/material"; - -import { - InfoOutlined, - AnalyticsOutlined, - Close as CloseIcon, -} from "@mui/icons-material"; - -import { TabColumns, TabResults, TabParameters } from "./tabs"; -import PlotLayoutForm from "./plotLayout/PlotLayoutForm"; -import { updateExplorerResults } from "../../../api/explorer"; -import { useSnackbar } from "notistack"; -import { useTranslation } from "react-i18next"; -import { formatDate } from "../../../utils"; - -export default function ExplorerDetailsModal({ - open = false, - onClose = () => {}, - explorer, - explorerComponent, - data, - setData, - dataType, - loading, - error = null, -}) { - const [currentTab, setCurrentTab] = useState(0); - const [localData, setLocalData] = useState(data); - const [formReady, setFormReady] = useState(false); - const { enqueueSnackbar } = useSnackbar(); - const { t } = useTranslation(["datasets", "common"]); - - const localDataRef = useRef(localData); - localDataRef.current = localData; - - // Sync data prop → localData when data arrives after mount (explorer results load async) - useEffect(() => { - if (data && !localData) { - setLocalData(data); - } - }, [data, localData]); - - useEffect(() => { - startTransition(() => setFormReady(true)); - }, []); - - if (!explorer) return null; - if (!data) return null; - - const tabs = [ - { label: t("common:results"), value: 0, icon: }, - { label: t("common:info"), value: 1, icon: }, - ]; - - const handleTabChange = (_, newValue) => { - startTransition(() => setCurrentTab(newValue)); - }; - - const handleSaveChangesLayout = useCallback(async () => { - const current = localDataRef.current; - try { - await updateExplorerResults(explorer.id, current); - setData(current); - enqueueSnackbar( - t("datasets:message.explorerResultsUpdatedSuccessfully"), - { variant: "success" }, - ); - } catch (error) { - console.error("Failed to update explorer results:", error); - enqueueSnackbar(t("datasets:error.failedToUpdateExplorerResults"), { - variant: "error", - }); - } - }, [explorer.id, setData, enqueueSnackbar, t]); - - const handleSetData = useCallback((newData) => { - setLocalData((prev) => ({ ...prev, data: newData })); - }, []); - - const handleSetLayout = useCallback((newLayout) => { - setLocalData((prev) => ({ ...prev, layout: newLayout })); - }, []); - - return ( - - - - {t("datasets:label.detailsForExplorer", { - name: explorerComponent.display_name, - })} - - - - - - - - - - - {/* Tab bar */} - - {tabs.map((tab) => ( - - ))} - - - {/* Tab content */} - - {/* Details tab */} - {currentTab === 1 && ( - - {/* Metadata strip */} - {explorer.created && ( - - - {t("common:created")} - - - {formatDate(explorer.created)} - - - )} - - {/* Columns + Parameters side by side */} - - - - - - - - - - )} - - {/* Results tab: always mounted to preserve state */} - - {!formReady && ( - - )} - {formReady && ( - <> - - - - - {dataType === "plotly_json" && ( - - - - )} - - )} - - - - - ); -} diff --git a/DashAI/front/src/components/notebooks/explorer/ExplorerInfoModal.jsx b/DashAI/front/src/components/notebooks/explorer/ExplorerInfoModal.jsx new file mode 100644 index 000000000..864209a6c --- /dev/null +++ b/DashAI/front/src/components/notebooks/explorer/ExplorerInfoModal.jsx @@ -0,0 +1,116 @@ +import React from "react"; +import { + Box, + Dialog, + DialogTitle, + DialogContent, + IconButton, + Typography, + Divider, +} from "@mui/material"; +import { Close as CloseIcon } from "@mui/icons-material"; + +import { TabColumns, TabParameters } from "./tabs"; +import { useTranslation } from "react-i18next"; +import { formatDate } from "../../../utils"; + +/** + * Read only detail view of one explorer: when it ran, which columns it used + * and which parameters it was given. The plot and everything that acts on it + * (edit, reset, download, fullscreen) live on the card, inside the plot's own + * action cluster, so this modal carries no results view. + */ +export default function ExplorerInfoModal({ + open = false, + onClose = () => {}, + explorer, + explorerComponent, +}) { + const { t } = useTranslation(["datasets", "common"]); + + if (!explorer) return null; + + return ( + + + + {t("datasets:label.detailsForExplorer", { + name: explorerComponent?.display_name, + })} + + + + + + + + + + + {/* Metadata strip */} + {explorer.created && ( + + + {t("common:created")} + + + {formatDate(explorer.created)} + + + )} + + {/* Columns + Parameters side by side */} + + + + + + + + + + + ); +} diff --git a/DashAI/front/src/components/notebooks/explorer/tabs/Results.jsx b/DashAI/front/src/components/notebooks/explorer/tabs/Results.jsx deleted file mode 100644 index 80f4b757e..000000000 --- a/DashAI/front/src/components/notebooks/explorer/tabs/Results.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react"; -import { Box, CircularProgress, Typography } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { visualizersKeys } from "../../../../utils/artifactVisualizerData"; -import ImageVisualizer from "../visualizations/ImageVisualizer"; -import PlotlyJsonVisualizer from "../visualizations/PlotlyJsonVisualizer"; -import TabularVisualizer from "../visualizations/TabularVisualizer"; - -/** - * Results component to render the results of the exploration - * @param {Object} props - * @param {Number} props.id The id of the exploration - * @param {Boolean} props.minimalist Whether to render in minimalist mode with fixed dimensions - * @param {Object} props.error Error raised while fetching the results, if any - */ -function Results({ id, minimalist = false, loading, data, dataType, error }) { - const { t } = useTranslation(["datasets"]); - - if (!id) return null; - - const containerStyles = minimalist - ? { - height: "100%", - width: "100%", - display: "flex", - alignItems: "center", - justifyContent: "center", - borderRadius: 1, - overflow: "hidden", - flexDirection: "column", - } - : { - height: "100%", - width: "100%", - display: "flex", - alignItems: "center", - justifyContent: "center", - overflow: "auto", - flexDirection: "column", - flex: 1, - }; - - return ( - - {loading && } - - {!loading && error && ( - - {t("datasets:error.explorerResultsUnavailable")} - - )} - - {!loading && !error && dataType === visualizersKeys.tabular && ( - - )} - - {!loading && !error && dataType === visualizersKeys.plotly_json && ( - - )} - - {!loading && !error && dataType === visualizersKeys.image_base64 && ( - - )} - - {!loading && !error && dataType === visualizersKeys.image_url && ( - - )} - - ); -} - -export default Results; diff --git a/DashAI/front/src/components/notebooks/explorer/tabs/index.jsx b/DashAI/front/src/components/notebooks/explorer/tabs/index.jsx index 5ce15ec7f..451ea30ea 100644 --- a/DashAI/front/src/components/notebooks/explorer/tabs/index.jsx +++ b/DashAI/front/src/components/notebooks/explorer/tabs/index.jsx @@ -1,4 +1,3 @@ export { default as TabInfo } from "./Info"; export { default as TabColumns } from "./Columns"; export { default as TabParameters } from "./Parameters"; -export { default as TabResults } from "./Results"; diff --git a/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx b/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx index a0d6cb5ea..8fb610d16 100644 --- a/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx +++ b/DashAI/front/src/components/notebooks/explorer/useExplorerResults.jsx @@ -1,16 +1,14 @@ import { useState, useEffect, useRef } from "react"; import { getExplorerResults } from "../../../api/explorer"; -import { artifactToVisualizerData } from "../../../utils/artifactVisualizerData"; /** * Hook to manage explorer results data - * @param {Number} id The id of the exploration - * @returns {Object} { loading, data, dataType, error, fetchExplorerResults } + * @param {Object} explorer The explorer whose results are fetched + * @returns {Object} { loading, artifact, error, fetchExplorerResults } */ export function useExplorerResults(explorer) { const [loading, setLoading] = useState(false); - const [dataType, setDataType] = useState(null); - const [data, setData] = useState(null); + const [artifact, setArtifact] = useState(null); const [error, setError] = useState(null); // Invalidates in-flight requests when the explorer changes or unmounts const requestIdRef = useRef(0); @@ -31,22 +29,19 @@ export function useExplorerResults(explorer) { const artifacts = await getExplorerResults(explorer.id); if (isStale()) return; - const [artifact] = artifacts ?? []; - if (!artifact?.type) { + const [firstArtifact] = artifacts ?? []; + if (!firstArtifact?.type) { throw new Error("No artifacts in the response"); } - const visualizerData = artifactToVisualizerData(artifact); - setDataType(visualizerData.dataType); - setData(visualizerData.data); + setArtifact(firstArtifact); } catch (err) { if (isStale()) return; // Results can disappear while the box is still mounted (explorer deleted, // result file removed): surface it as state instead of letting the // rejection escape the effect as an uncaught runtime error. console.error("Error fetching explorer results:", err); - setDataType(null); - setData(null); + setArtifact(null); setError(err); } finally { if (!isStale()) { @@ -65,9 +60,7 @@ export function useExplorerResults(explorer) { return { loading, - data, - setData, - dataType, + artifact, error, fetchExplorerResults, }; diff --git a/DashAI/front/src/components/notebooks/explorer/visualizations/PlotlyJsonVisualizer.jsx b/DashAI/front/src/components/notebooks/explorer/visualizations/PlotlyJsonVisualizer.jsx index 1fca87070..ad83d4934 100644 --- a/DashAI/front/src/components/notebooks/explorer/visualizations/PlotlyJsonVisualizer.jsx +++ b/DashAI/front/src/components/notebooks/explorer/visualizations/PlotlyJsonVisualizer.jsx @@ -10,6 +10,8 @@ import RestartAltIcon from "@mui/icons-material/RestartAlt"; import FullscreenIcon from "@mui/icons-material/Fullscreen"; import FullscreenExitIcon from "@mui/icons-material/FullscreenExit"; import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; +import { buildAxisResetUpdate } from "../../../../utils/plotlyAxes"; +import { buildPlotMargin } from "../../../../utils/plotlyMargin"; const MIN_WIDTH = 300; const MIN_HEIGHT_MINIMALIST = 200; @@ -105,10 +107,13 @@ function PlotlyJsonVisualizer({ }; const handleReset = (ref) => { - relayout(ref, { - "xaxis.autorange": true, - "yaxis.autorange": true, - }); + const el = ref?.current?.el; + if (!el) return; + // Mirrors the zoom handlers above, which already bail when the figure has + // no cartesian axes. Parallel coordinates, pie and polar figures have + // none, and autoranging an axis that does not exist throws inside Plotly. + const update = buildAxisResetUpdate(el._fullLayout); + if (update) relayout(ref, update); }; const handleDownload = (ref, format = "svg") => { @@ -125,6 +130,13 @@ function PlotlyJsonVisualizer({ } }; + // Fall back to the app theme when the figure does not pin its own colors, + // so a computed plot tracks light/dark while a user edited one keeps the + // colors it was saved with. + const themeBg = theme.palette.background.paper; + const themeText = theme.palette.text.primary; + const themeGrid = theme.palette.divider; + const plotConfig = { responsive: true, displaylogo: false, @@ -133,32 +145,33 @@ function PlotlyJsonVisualizer({ const plotLayout = { ...plotData.layout, - paper_bgcolor: plotData.layout?.paper_bgcolor || "white", - plot_bgcolor: plotData.layout?.plot_bgcolor || "white", - margin: minimalist - ? { - l: 40, - r: 20, - t: 30, - b: 40, - ...plotData.layout?.margin, - } - : { - l: 60, - r: 30, - t: 50, - b: 60, - ...plotData.layout?.margin, - }, + paper_bgcolor: plotData.layout?.paper_bgcolor || themeBg, + plot_bgcolor: plotData.layout?.plot_bgcolor || themeBg, + margin: { + ...buildPlotMargin(plotData, minimalist), + ...plotData.layout?.margin, + }, autosize: true, font: { size: minimalist ? 10 : 12, + color: themeText, ...plotData.layout?.font, }, + xaxis: { + gridcolor: themeGrid, + zerolinecolor: themeGrid, + ...plotData.layout?.xaxis, + }, + yaxis: { + gridcolor: themeGrid, + zerolinecolor: themeGrid, + ...plotData.layout?.yaxis, + }, title: { ...plotData.layout?.title, font: { size: minimalist ? 12 : 16, + color: themeText, ...plotData.layout?.title?.font, }, }, @@ -318,7 +331,7 @@ function PlotlyJsonVisualizer({ onClose={() => setExpanded(false)} slotProps={{ paper: { - sx: { bgcolor: "white" }, + sx: { bgcolor: "background.default" }, }, }} > @@ -332,8 +345,8 @@ function PlotlyJsonVisualizer({ data={plotData.data} layout={{ ...plotData.layout, - paper_bgcolor: plotData.layout?.paper_bgcolor || "white", - plot_bgcolor: plotData.layout?.plot_bgcolor || "white", + paper_bgcolor: plotData.layout?.paper_bgcolor || themeBg, + plot_bgcolor: plotData.layout?.plot_bgcolor || themeBg, autosize: true, margin: { l: 80, @@ -344,12 +357,14 @@ function PlotlyJsonVisualizer({ }, font: { size: 14, + color: themeText, ...plotData.layout?.font, }, title: { ...plotData.layout?.title, font: { size: 18, + color: themeText, ...plotData.layout?.title?.font, }, }, diff --git a/DashAI/front/src/components/notebooks/notebook/NotebookView.jsx b/DashAI/front/src/components/notebooks/notebook/NotebookView.jsx index df5592215..8e711100e 100644 --- a/DashAI/front/src/components/notebooks/notebook/NotebookView.jsx +++ b/DashAI/front/src/components/notebooks/notebook/NotebookView.jsx @@ -18,6 +18,11 @@ import { deleteConverterById } from "../../../api/converter"; import { startJobPolling } from "../../../utils/jobPoller"; import { useTranslation } from "react-i18next"; +// Height of one explorer/converter cell. Explorer cards measure their leftover +// space and size their plot to it, so changing this number is all it takes to +// give the figures more room. +const CARD_HEIGHT = "520px"; + const RowItem = React.memo(function RowItem({ item, handleExplorerDeleteClick, @@ -30,7 +35,7 @@ const RowItem = React.memo(function RowItem({ sx={{ my: 4, p: 1.5, - height: "394px", + height: CARD_HEIGHT, }} > {item.type === "explorer" ? ( diff --git a/DashAI/front/src/components/shared/ArtifactViewer.jsx b/DashAI/front/src/components/shared/ArtifactViewer.jsx index cbff74873..8c6a51488 100644 --- a/DashAI/front/src/components/shared/ArtifactViewer.jsx +++ b/DashAI/front/src/components/shared/ArtifactViewer.jsx @@ -41,6 +41,7 @@ export default function ArtifactViewer({ canReset = false, siblingArtifacts = null, siblingIndex = 0, + height = null, }) { const theme = useTheme(); const { t } = useTranslation(["explainers", "common"]); @@ -278,8 +279,14 @@ export default function ArtifactViewer({ {/* The instance label is shown once by the parent; suppress the - per artifact title so it is not repeated on every block. */} - + per artifact title so it is not repeated on every block. Callers + that live in a fixed size container (an explorer card) pass an + explicit height so the figure fits its box instead of overflowing + it; everyone else gets the renderer's own default. */} + {/* Edit dialog: a live plot preview beside the shared form layout editor (reused from the explorer view). The form mutates editData / @@ -467,4 +474,5 @@ ArtifactViewer.propTypes = { canReset: PropTypes.bool, siblingArtifacts: PropTypes.array, siblingIndex: PropTypes.number, + height: PropTypes.number, }; diff --git a/DashAI/front/src/components/shared/RunStatusDot.jsx b/DashAI/front/src/components/shared/RunStatusDot.jsx index 9f279a83a..c209cbc2c 100644 --- a/DashAI/front/src/components/shared/RunStatusDot.jsx +++ b/DashAI/front/src/components/shared/RunStatusDot.jsx @@ -3,9 +3,12 @@ import { useTheme } from "@mui/material/styles"; import PropTypes from "prop-types"; import { getRunStatusColor } from "../../utils/runStatus"; -export default function RunStatusDot({ status, size, sx }) { +export default function RunStatusDot({ status, size, sx, colorKey }) { const theme = useTheme(); - const statusColorKey = getRunStatusColor(status); + // An explicit colorKey overrides the status-derived one, letting a caller + // signal "in progress" (e.g. a queued job still at NOT_STARTED) with a color + // its raw status would not map to. + const statusColorKey = colorKey ?? getRunStatusColor(status); const statusMain = statusColorKey === "default" ? theme.palette.text.disabled @@ -32,9 +35,11 @@ RunStatusDot.propTypes = { status: PropTypes.number.isRequired, size: PropTypes.number, sx: PropTypes.object, + colorKey: PropTypes.oneOf(["default", "info", "success", "error", "warning"]), }; RunStatusDot.defaultProps = { size: 8, sx: {}, + colorKey: undefined, }; diff --git a/DashAI/front/src/utils/i18n/locales/de/common.json b/DashAI/front/src/utils/i18n/locales/de/common.json index 06729b236..06d0e5962 100644 --- a/DashAI/front/src/utils/i18n/locales/de/common.json +++ b/DashAI/front/src/utils/i18n/locales/de/common.json @@ -59,7 +59,6 @@ "image": "Bild", "index": "Index", "info": "Info", - "infoEdit": "Info/Bearbeiten", "items": "Elemente", "itemsToBeDeleted": "Die folgenden Elemente werden gelöscht:", "json": "JSON", diff --git a/DashAI/front/src/utils/i18n/locales/de/datasets.json b/DashAI/front/src/utils/i18n/locales/de/datasets.json index dc7bc2ae7..3a99448b8 100644 --- a/DashAI/front/src/utils/i18n/locales/de/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/de/datasets.json @@ -77,6 +77,7 @@ "failedToLoadDatasetInfo": "Datensatzinformationen konnten nicht abgerufen werden", "failedToUpdateDataset": "Datensatz konnte nicht aktualisiert werden", "failedToUpdateExplorerResults": "Explorer-Ergebnisse konnten nicht aktualisiert werden", + "failedToResetExplorerResults": "Ursprüngliches Diagramm konnte nicht wiederhergestellt werden", "failedToUpdateNotebook": "Notizbuch konnte nicht aktualisiert werden", "fetchingDataloaders": "Fehler beim Abrufen kompatibler Datenlader.", "fetchingDatasetColumns": "Fehler beim Abrufen der Datensatzspalten.", @@ -418,6 +419,7 @@ "folderDeleteSuccess": "Ordner erfolgreich gelöscht", "explorerProcessedSuccessfully": "Explorer {{name}} erfolgreich verarbeitet", "explorerResultsUpdatedSuccessfully": "Explorer-Ergebnisse erfolgreich aktualisiert", + "explorerResultsRestoredSuccessfully": "Ursprüngliches Diagramm wiederhergestellt", "noChangesMade": "Es wurden keine Änderungen vorgenommen", "notebookCreated": "Notizbuch erfolgreich erstellt", "notebookUpdateSuccess": "Notizbuch erfolgreich aktualisiert" diff --git a/DashAI/front/src/utils/i18n/locales/en/common.json b/DashAI/front/src/utils/i18n/locales/en/common.json index 9bdd065c8..bc2b94fe1 100644 --- a/DashAI/front/src/utils/i18n/locales/en/common.json +++ b/DashAI/front/src/utils/i18n/locales/en/common.json @@ -59,7 +59,6 @@ "image": "Image", "index": "Index", "info": "Info", - "infoEdit": "Info/Edit", "items": "Items", "itemsToBeDeleted": "The following items will be deleted:", "json": "JSON", diff --git a/DashAI/front/src/utils/i18n/locales/en/datasets.json b/DashAI/front/src/utils/i18n/locales/en/datasets.json index 475f2ff14..ae526e153 100644 --- a/DashAI/front/src/utils/i18n/locales/en/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/en/datasets.json @@ -75,6 +75,7 @@ "failedToLoadDatasetInfo": "Failed to fetch dataset info", "failedToUpdateDataset": "Failed to update dataset", "failedToUpdateExplorerResults": "Failed to update explorer results", + "failedToResetExplorerResults": "Failed to restore the original plot", "failedToUpdateNotebook": "Failed to update notebook", "fetchingDataloaders": "Error while trying to obtain compatible dataloaders.", "fetchingDatasetColumns": "Error while trying to obtain the dataset columns.", @@ -414,6 +415,7 @@ "folderDeleteSuccess": "Folder deleted successfully", "explorerProcessedSuccessfully": "Explorer {{name}} processed successfully", "explorerResultsUpdatedSuccessfully": "Explorer results updated successfully", + "explorerResultsRestoredSuccessfully": "Original plot restored", "noChangesMade": "No changes were made", "notebookCreated": "Notebook created successfully", "notebookUpdateSuccess": "Notebook updated successfully" diff --git a/DashAI/front/src/utils/i18n/locales/es/common.json b/DashAI/front/src/utils/i18n/locales/es/common.json index 78d8c4d39..e40814899 100644 --- a/DashAI/front/src/utils/i18n/locales/es/common.json +++ b/DashAI/front/src/utils/i18n/locales/es/common.json @@ -59,7 +59,6 @@ "image": "Imagen", "index": "Índice", "info": "Información", - "infoEdit": "Información/Editar", "items": "Elementos", "itemsToBeDeleted": "Los siguientes elementos serán eliminados:", "json": "JSON", diff --git a/DashAI/front/src/utils/i18n/locales/es/datasets.json b/DashAI/front/src/utils/i18n/locales/es/datasets.json index 7e50e1865..6a57f4f20 100644 --- a/DashAI/front/src/utils/i18n/locales/es/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/es/datasets.json @@ -78,6 +78,7 @@ "failedToLoadDatasetInfo": "Fallo al obtener información del dataset", "failedToUpdateDataset": "Fallo al actualizar dataset", "failedToUpdateExplorerResults": "Fallo al actualizar resultados del explorador", + "failedToResetExplorerResults": "Fallo al restaurar el gráfico original", "failedToUpdateNotebook": "Fallo al actualizar cuaderno", "fetchingDataloaders": "Error al intentar obtener dataloaders compatibles.", "fetchingDatasetColumns": "Error al intentar obtener las columnas del dataset.", @@ -426,6 +427,7 @@ "folderDeleteSuccess": "Carpeta eliminada exitosamente", "explorerProcessedSuccessfully": "Explorador {{name}} procesado exitosamente", "explorerResultsUpdatedSuccessfully": "Resultados del explorador actualizados exitosamente", + "explorerResultsRestoredSuccessfully": "Gráfico original restaurado", "noChangesMade": "No se realizaron cambios", "notebookCreated": "Cuaderno creado exitosamente", "notebookUpdateSuccess": "Cuaderno actualizado exitosamente" diff --git a/DashAI/front/src/utils/i18n/locales/pt/common.json b/DashAI/front/src/utils/i18n/locales/pt/common.json index 2dbb2c145..b70ab5a97 100644 --- a/DashAI/front/src/utils/i18n/locales/pt/common.json +++ b/DashAI/front/src/utils/i18n/locales/pt/common.json @@ -59,7 +59,6 @@ "image": "Imagem", "index": "Índice", "info": "Informação", - "infoEdit": "Informação/Editar", "items": "Itens", "itemsToBeDeleted": "Os seguintes itens serão excluídos:", "json": "JSON", diff --git a/DashAI/front/src/utils/i18n/locales/pt/datasets.json b/DashAI/front/src/utils/i18n/locales/pt/datasets.json index 96b1e863c..1b6d1b81f 100644 --- a/DashAI/front/src/utils/i18n/locales/pt/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/pt/datasets.json @@ -78,6 +78,7 @@ "failedToLoadDatasetInfo": "Falha ao obter informações do conjunto de dados", "failedToUpdateDataset": "Falha ao atualizar conjunto de dados", "failedToUpdateExplorerResults": "Falha ao atualizar resultados do explorador", + "failedToResetExplorerResults": "Falha ao restaurar o gráfico original", "failedToUpdateNotebook": "Falha ao atualizar caderno", "fetchingDataloaders": "Erro ao tentar obter dataloaders compatíveis.", "fetchingDatasetColumns": "Erro ao tentar obter as colunas do conjunto de dados.", @@ -426,6 +427,7 @@ "folderDeleteSuccess": "Pasta excluída com sucesso", "explorerProcessedSuccessfully": "Explorador {{name}} processado com sucesso", "explorerResultsUpdatedSuccessfully": "Resultados do explorador atualizados com sucesso", + "explorerResultsRestoredSuccessfully": "Gráfico original restaurado", "noChangesMade": "Nenhuma alteração realizada", "notebookCreated": "Caderno criado com sucesso", "notebookUpdateSuccess": "Caderno atualizado com sucesso" diff --git a/DashAI/front/src/utils/i18n/locales/zh/common.json b/DashAI/front/src/utils/i18n/locales/zh/common.json index 5c1e1b387..22f6a9f14 100644 --- a/DashAI/front/src/utils/i18n/locales/zh/common.json +++ b/DashAI/front/src/utils/i18n/locales/zh/common.json @@ -59,7 +59,6 @@ "image": "图像", "index": "索引", "info": "信息", - "infoEdit": "信息/编辑", "items": "项目", "itemsToBeDeleted": "以下项目将被删除:", "json": "JSON", diff --git a/DashAI/front/src/utils/i18n/locales/zh/datasets.json b/DashAI/front/src/utils/i18n/locales/zh/datasets.json index 93f0f9109..75e2eecd1 100644 --- a/DashAI/front/src/utils/i18n/locales/zh/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/zh/datasets.json @@ -76,6 +76,7 @@ "failedToLoadDatasetInfo": "获取数据集信息失败", "failedToUpdateDataset": "更新数据集失败", "failedToUpdateExplorerResults": "更新探索器结果失败", + "failedToResetExplorerResults": "恢复原始图表失败", "failedToUpdateNotebook": "更新笔记本失败", "fetchingDataloaders": "获取兼容数据加载器时出错。", "fetchingDatasetColumns": "获取数据集列时出错。", @@ -467,6 +468,7 @@ "folderDeleteSuccess": "文件夹删除成功", "explorerProcessedSuccessfully": "探索器 {{name}} 处理成功", "explorerResultsUpdatedSuccessfully": "探索器结果更新成功", + "explorerResultsRestoredSuccessfully": "已恢复原始图表", "noChangesMade": "未做任何更改", "notebookCreated": "笔记本创建成功", "notebookUpdateSuccess": "笔记本更新成功" diff --git a/DashAI/front/src/utils/plotlyAxes.js b/DashAI/front/src/utils/plotlyAxes.js new file mode 100644 index 000000000..c20e8f1b8 --- /dev/null +++ b/DashAI/front/src/utils/plotlyAxes.js @@ -0,0 +1,18 @@ +/** + * Build the relayout payload that resets a figure's cartesian axes. + * + * Only axes present in the figure's computed layout are included. Non + * cartesian figures (parallel coordinates, pie, polar) carry no `xaxis` or + * `yaxis` there, and asking Plotly to autorange an axis it does not have + * throws inside its own `relayout` while reading `_inputDomain`. + * + * @param {object} fullLayout The plot element's `_fullLayout`. + * @returns {object|null} The relayout payload, or null when the figure has no + * cartesian axis to reset. + */ +export function buildAxisResetUpdate(fullLayout) { + const update = {}; + if (fullLayout?.xaxis) update["xaxis.autorange"] = true; + if (fullLayout?.yaxis) update["yaxis.autorange"] = true; + return Object.keys(update).length > 0 ? update : null; +} diff --git a/DashAI/front/src/utils/plotlyAxes.test.js b/DashAI/front/src/utils/plotlyAxes.test.js new file mode 100644 index 000000000..9c4ebfedf --- /dev/null +++ b/DashAI/front/src/utils/plotlyAxes.test.js @@ -0,0 +1,39 @@ +import { buildAxisResetUpdate } from "./plotlyAxes"; + +describe("buildAxisResetUpdate", () => { + it("autoranges both axes of a cartesian figure", () => { + const fullLayout = { xaxis: { range: [0, 1] }, yaxis: { range: [0, 1] } }; + + expect(buildAxisResetUpdate(fullLayout)).toEqual({ + "xaxis.autorange": true, + "yaxis.autorange": true, + }); + }); + + it("returns null for a parallel coordinates figure", () => { + // parcoords is not cartesian: its computed layout carries no xaxis or + // yaxis, and asking Plotly to autorange one throws inside relayout. + const fullLayout = { dragmode: "zoom", margin: {} }; + + expect(buildAxisResetUpdate(fullLayout)).toBeNull(); + }); + + it("returns null for a polar figure", () => { + const fullLayout = { polar: { radialaxis: {}, angularaxis: {} } }; + + expect(buildAxisResetUpdate(fullLayout)).toBeNull(); + }); + + it("autoranges only the axis that exists", () => { + const fullLayout = { xaxis: { range: [0, 1] } }; + + expect(buildAxisResetUpdate(fullLayout)).toEqual({ + "xaxis.autorange": true, + }); + }); + + it("returns null when the layout is missing entirely", () => { + expect(buildAxisResetUpdate(undefined)).toBeNull(); + expect(buildAxisResetUpdate(null)).toBeNull(); + }); +}); diff --git a/DashAI/front/src/utils/plotlyMargin.js b/DashAI/front/src/utils/plotlyMargin.js new file mode 100644 index 000000000..c5481db30 --- /dev/null +++ b/DashAI/front/src/utils/plotlyMargin.js @@ -0,0 +1,37 @@ +/** + * Traces whose axis labels sit along the top of the plot domain, where a + * figure title would otherwise be drawn. + */ +const TOP_LABEL_TRACE_TYPES = ["parcoords", "parcats"]; + +function hasTopLabelTrace(data) { + return ( + Array.isArray(data) && + data.some((trace) => TOP_LABEL_TRACE_TYPES.includes(trace?.type)) + ); +} + +function hasTitle(layout) { + const title = layout?.title; + const text = typeof title === "string" ? title : title?.text; + return typeof text === "string" && text.trim().length > 0; +} + +/** + * Build the default plot margin for {@link PlotlyJsonVisualizer}. + * + * A titled parallel coordinates or categories figure gets extra top room: its + * dimension labels are drawn along the top of the plot domain, in the same + * band as the title, so the standard top margin would let them overlap. + * + * @param {object} plotData The parsed `{data, layout}` figure. + * @param {boolean} minimalist Whether the compact card preview is rendering. + * @returns {{l: number, r: number, t: number, b: number}} The base margin, + * before any figure-supplied margin is spread over it. + */ +export function buildPlotMargin(plotData, minimalist) { + if (minimalist) return { l: 40, r: 20, t: 30, b: 40 }; + const t = + hasTopLabelTrace(plotData?.data) && hasTitle(plotData?.layout) ? 90 : 50; + return { l: 60, r: 30, t, b: 60 }; +} diff --git a/DashAI/front/src/utils/plotlyMargin.test.js b/DashAI/front/src/utils/plotlyMargin.test.js new file mode 100644 index 000000000..a8551a7e9 --- /dev/null +++ b/DashAI/front/src/utils/plotlyMargin.test.js @@ -0,0 +1,66 @@ +import { buildPlotMargin } from "./plotlyMargin"; + +describe("buildPlotMargin", () => { + it("uses the compact margin in minimalist mode", () => { + expect(buildPlotMargin({ data: [], layout: {} }, true)).toEqual({ + l: 40, + r: 20, + t: 30, + b: 40, + }); + }); + + it("uses the standard top margin for a cartesian figure with a title", () => { + const plotData = { + data: [{ type: "scatter" }], + layout: { title: { text: "My plot" } }, + }; + + expect(buildPlotMargin(plotData, false)).toEqual({ + l: 60, + r: 30, + t: 50, + b: 60, + }); + }); + + it("adds top clearance for a titled parallel coordinates figure", () => { + // parcoords draws its dimension labels along the top of the plot domain, + // where the title also sits; the title needs extra room to clear them. + const plotData = { + data: [{ type: "parcoords" }], + layout: { title: { text: "Correlations" } }, + }; + + expect(buildPlotMargin(plotData, false).t).toBeGreaterThan(50); + }); + + it("adds top clearance for a titled parallel categories figure", () => { + const plotData = { + data: [{ type: "parcats" }], + layout: { title: "Flows" }, + }; + + expect(buildPlotMargin(plotData, false).t).toBeGreaterThan(50); + }); + + it("keeps the standard top margin for parcoords without a title", () => { + const plotData = { data: [{ type: "parcoords" }], layout: {} }; + + expect(buildPlotMargin(plotData, false).t).toBe(50); + }); + + it("ignores an empty title string", () => { + const plotData = { + data: [{ type: "parcoords" }], + layout: { title: " " }, + }; + + expect(buildPlotMargin(plotData, false).t).toBe(50); + }); + + it("survives missing data or layout", () => { + expect(buildPlotMargin({}, false)).toEqual({ l: 60, r: 30, t: 50, b: 60 }); + expect(buildPlotMargin(undefined, false).t).toBe(50); + }); +}); diff --git a/tests/back/api/test_explainers_overrides.py b/tests/back/api/test_explainers_overrides.py index f003139a5..9ba968f40 100644 --- a/tests/back/api/test_explainers_overrides.py +++ b/tests/back/api/test_explainers_overrides.py @@ -1,4 +1,4 @@ -"""Unit tests for the ``_apply_overrides`` helper in explainers endpoints. +"""Unit tests for the shared ``apply_plot_overrides`` helper. These tests import only the pure helper function, not the FastAPI app, so they can run without the heavy explainer dependencies (grad_cam, dice_ml, @@ -13,7 +13,7 @@ import json -from DashAI.back.api.api_v1.endpoints.explainers import _apply_overrides +from DashAI.back.core.artifacts import apply_plot_overrides def test_apply_overrides_replaces_plotly_payload(): @@ -23,7 +23,7 @@ def test_apply_overrides_replaces_plotly_payload(): ] figure = {"data": [], "layout": {"title": "edited"}} - result = _apply_overrides(artifacts, {"0": figure}) + result = apply_plot_overrides(artifacts, {"0": figure}) assert result[0]["payload"] != "original" assert json.loads(result[0]["payload"]) == figure @@ -35,7 +35,7 @@ def test_apply_overrides_leaves_non_plotly_artifact_unchanged(): {"type": "image", "payload": "original-image", "title": "Image 0", "index": 0}, ] - result = _apply_overrides(artifacts, {"0": {"data": [], "layout": {}}}) + result = apply_plot_overrides(artifacts, {"0": {"data": [], "layout": {}}}) assert result[0]["payload"] == "original-image" @@ -46,7 +46,7 @@ def test_apply_overrides_ignores_out_of_range_index(): {"type": "plotly", "payload": "original", "title": "Plot 0", "index": 0}, ] - result = _apply_overrides(artifacts, {"5": {"data": []}}) + result = apply_plot_overrides(artifacts, {"5": {"data": []}}) assert result[0]["payload"] == "original" @@ -57,5 +57,35 @@ def test_apply_overrides_returns_unchanged_for_none_or_empty(): {"type": "plotly", "payload": "original", "title": "Plot 0"}, ] - assert _apply_overrides(artifacts, None) == artifacts - assert _apply_overrides(artifacts, {}) == artifacts + assert apply_plot_overrides(artifacts, None) == artifacts + assert apply_plot_overrides(artifacts, {}) == artifacts + + +def test_apply_overrides_replaces_leaf_nested_in_group(): + """An override reaches a plotly leaf nested inside a grouped artifact.""" + artifacts = [ + { + "type": "grouped", + "title": "Instances", + "groups": [ + { + "title": "Instance 0", + "artifacts": [ + { + "type": "plotly", + "payload": "original", + "title": "Plot", + "index": 0, + }, + ], + }, + ], + }, + ] + figure = {"data": [], "layout": {"title": "edited"}} + + result = apply_plot_overrides(artifacts, {"0": figure}) + leaf = result[0]["groups"][0]["artifacts"][0] + + assert json.loads(leaf["payload"]) == figure + assert leaf["overridden"] is True diff --git a/tests/back/api/test_explorer_overrides.py b/tests/back/api/test_explorer_overrides.py new file mode 100644 index 000000000..ada6bf742 --- /dev/null +++ b/tests/back/api/test_explorer_overrides.py @@ -0,0 +1,171 @@ +"""Tests for explorer plot override persistence and reset.""" + +import json +import pathlib + +from DashAI.back.core.enums.status import ExplorerStatus +from DashAI.back.dependencies.database.models import Dataset, Explorer, Notebook +from DashAI.back.exploration.artifact_store import write_artifacts + +ORIGINAL_FIGURE = { + "data": [{"y": [1, 2, 3], "type": "bar"}], + "layout": {"title": "original"}, +} +EDITED_FIGURE = { + "data": [{"y": [1, 2, 3], "type": "bar"}], + "layout": {"title": "edited"}, +} + + +def _make_explorer(client, tmp_path_name="explorer_artifacts"): + """Create a finished explorer row with one stored plotly artifact. + + Parameters + ---------- + client : TestClient + The app test client, used to reach the app's session factory and + local path. + tmp_path_name : str + Subdirectory name under the app local path holding the artifacts. + + Returns + ------- + tuple + ``(explorer_id, artifacts_path)``. + """ + services = client.app.container._services + session_factory = services["session_factory"] + local_path = pathlib.Path(services["config"]["LOCAL_PATH"]) + + with session_factory() as db: + dataset = Dataset(name=f"ds-{tmp_path_name}", file_path="/tmp/ds") + db.add(dataset) + db.commit() + notebook = Notebook(dataset_id=dataset.id, file_path="/tmp/nb") + db.add(notebook) + db.commit() + explorer = Explorer( + notebook_id=notebook.id, + columns=[], + exploration_type="TestExplorer", + parameters={}, + status=ExplorerStatus.FINISHED, + ) + db.add(explorer) + db.commit() + explorer_id = explorer.id + + artifacts_path = local_path / tmp_path_name / f"{explorer_id}_artifacts.json" + write_artifacts( + artifacts_path, + [ + { + "type": "plotly", + "payload": json.dumps(ORIGINAL_FIGURE), + "title": "Plot", + "role": "explanation", + "index": 0, + } + ], + ) + explorer.artifacts_path = artifacts_path.as_posix() + db.commit() + + return explorer_id, artifacts_path + + +def test_explorer_model_has_plot_overrides_column(client): + """The explorer table stores plot overrides.""" + explorer_id, _ = _make_explorer(client, "col_check") + session_factory = client.app.container._services["session_factory"] + + with session_factory() as db: + explorer = db.get(Explorer, explorer_id) + explorer.plot_overrides = {"0": json.dumps(EDITED_FIGURE)} + db.commit() + + with session_factory() as db: + explorer = db.get(Explorer, explorer_id) + assert json.loads(explorer.plot_overrides["0"]) == EDITED_FIGURE + + +def test_put_stores_override_without_touching_artifacts_file(client): + """Saving an edit writes an override and leaves the stored figure intact.""" + explorer_id, artifacts_path = _make_explorer(client, "put_check") + before = artifacts_path.read_text(encoding="utf-8") + + response = client.put( + f"/api/v1/explorer/{explorer_id}/results/", + json={"index": 0, "figure": EDITED_FIGURE}, + ) + + assert response.status_code == 200 + assert artifacts_path.read_text(encoding="utf-8") == before + + session_factory = client.app.container._services["session_factory"] + with session_factory() as db: + explorer = db.get(Explorer, explorer_id) + assert json.loads(explorer.plot_overrides["0"]) == EDITED_FIGURE + + +def test_results_returns_override_flagged_as_overridden(client): + """The read endpoint serves the edited figure and flags it.""" + explorer_id, _ = _make_explorer(client, "read_check") + client.put( + f"/api/v1/explorer/{explorer_id}/results/", + json={"index": 0, "figure": EDITED_FIGURE}, + ) + + response = client.post( + f"/api/v1/explorer/{explorer_id}/results/", json={"options": {}} + ) + + assert response.status_code == 200 + artifact = response.json()[0] + assert json.loads(artifact["payload"]) == EDITED_FIGURE + assert artifact["overridden"] is True + + +def test_delete_override_restores_the_computed_figure(client): + """Reset drops the override so the original figure is served again.""" + explorer_id, _ = _make_explorer(client, "reset_check") + client.put( + f"/api/v1/explorer/{explorer_id}/results/", + json={"index": 0, "figure": EDITED_FIGURE}, + ) + + response = client.delete(f"/api/v1/explorer/{explorer_id}/results/override/0") + assert response.status_code == 200 + + results = client.post( + f"/api/v1/explorer/{explorer_id}/results/", json={"options": {}} + ).json() + assert json.loads(results[0]["payload"]) == ORIGINAL_FIGURE + assert "overridden" not in results[0] + + session_factory = client.app.container._services["session_factory"] + with session_factory() as db: + assert db.get(Explorer, explorer_id).plot_overrides is None + + +def test_delete_missing_override_is_a_no_op(client): + """Resetting an artifact that was never edited succeeds.""" + explorer_id, _ = _make_explorer(client, "noop_check") + + response = client.delete(f"/api/v1/explorer/{explorer_id}/results/override/3") + + assert response.status_code == 200 + + +def test_override_endpoints_404_on_unknown_explorer(client): + """Both override endpoints reject an explorer id that does not exist.""" + assert ( + client.put( + "/api/v1/explorer/999999/results/", + json={"index": 0, "figure": EDITED_FIGURE}, + ).status_code + == 404 + ) + assert ( + client.delete("/api/v1/explorer/999999/results/override/0").status_code == 404 + )