diff --git a/client/src/components/InspectDrawer.tsx b/client/src/components/InspectDrawer.tsx index a46a0f0..eba3d63 100644 --- a/client/src/components/InspectDrawer.tsx +++ b/client/src/components/InspectDrawer.tsx @@ -90,6 +90,9 @@ interface Props { glRef: RefObject; commandCentre: RefObject; cootInitialized: boolean; + // Deep-link target: open in Site mode focused on this site_num (from a + // dashboard site-bar click). null = normal entry. + initialSite?: number | null; } // PanDDA event maps are contoured in ABSOLUTE map units, not σ. They're @@ -216,6 +219,7 @@ export function InspectDrawer({ glRef, commandCentre, cootInitialized, + initialSite, }: Props) { const dispatch = useDispatch(); // Redux is the LIVE source of truth for map contour/visibility/active state — @@ -241,6 +245,10 @@ export function InspectDrawer({ ); const [datasets, setDatasets] = useState([]); const [axis, setAxis] = useState("dataset"); + // Ref mirror so loadEvent picks the model rendition for the CURRENT tab + // (ribbons in Site mode) without being re-created on every axis change. + const axisRef = useRef(axis); + axisRef.current = axis; const [sort, setSort] = useState("dtag"); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("active"); @@ -434,13 +442,25 @@ export function InspectDrawer({ // Non-fatal: fall back to bare-atom rendering. } } - await mol.addRepresentation("CBs", "/*/*"); + // Backbone rendition follows the grouping tab: in SITE mode draw + // RIBBONS (CRs) for spatial context while scanning sites, plus the + // bound ligand as sticks ("ligands") so the site itself stays + // visible; otherwise bonds (CBs) for detailed building. Read the ref + // so it tracks the tab at load time. NB switching tabs re-skins on + // the NEXT model load, not the currently-shown one. + const modelStyles = + axisRef.current === "site" ? ["CRs", "ligands"] : ["CBs"]; + for (const style of modelStyles) { + await mol.addRepresentation(style, "/*/*"); + } // addDict does NOT redraw, so the first draw above perceives bonds // without the dict (all single bonds). Re-perceive WITH the dict so // aromatic/double orders render — the proven 0.23 dirty+redraw. if (dictLoaded) { mol.setAtomsDirty(true); - await mol.fetchIfDirtyAndDraw("CBs"); + for (const style of modelStyles) { + await mol.fetchIfDirtyAndDraw(style); + } } // Hide H on the freshly-loaded model if the toggle is on (default). // Read the REF so a fresh model adopts the current preference. No-op @@ -799,6 +819,26 @@ export function InspectDrawer({ ); loadEventRef.current = loadEvent; + // Deep-link consume: arriving via ?site=N (a dashboard site-bar click) opens + // the viewer in SITE mode focused on that site. Wait until datasets + Coot are + // ready, then switch the tab and load the site's first event. Consume ONCE (a + // ref guard) so later renders don't re-fire it. A fresh navigation is a fresh + // InspectPage mount, so the guard resets and re-clicking a bar works. + const consumedSiteRef = useRef(false); + useEffect(() => { + if (consumedSiteRef.current) return; + if (initialSite == null || !cootInitialized || datasets.length === 0) { + return; + } + const ev = datasets + .flatMap((d) => d.events) + .find((e) => e.site_num === initialSite); + consumedSiteRef.current = true; + if (!ev) return; + setAxis("site"); + loadEvent(ev); + }, [initialSite, cootInitialized, datasets, loadEvent]); + // Contour ONE of the loaded maps (by molNo). The slider value is in that // map's native unit: event maps ABSOLUTE (pass straight to Coot), model maps // σ (multiply by RMSD — Coot always contours in absolute). Dispatch — the diff --git a/client/src/components/SummaryCharts.tsx b/client/src/components/SummaryCharts.tsx index b226a45..3fe087a 100644 --- a/client/src/components/SummaryCharts.tsx +++ b/client/src/components/SummaryCharts.tsx @@ -86,9 +86,12 @@ function Histogram({ export function SummaryCharts({ distributions, sites, + onSiteClick, }: { distributions: Distributions; sites: SiteSummary[]; + // Clicking a site bar jumps to that site in the Moorhen viewer (deep-link). + onSiteClick?: (siteNum: number) => void; }) { // Events-per-site, STACKED by decision (replaces PanDDA1's // analyse_events_site_N pies). One bar per site whose total height is the @@ -180,6 +183,20 @@ export function SummaryCharts({ ticks: { precision: 0 }, }, }, + // Click a bar → open that site in Moorhen (deep-link). The + // clicked element's index maps to sites[index]; pointer cursor + // on hover signals the affordance. + onClick: (_evt, elements) => { + const i = elements?.[0]?.index; + if (i != null && sites[i]) onSiteClick?.(sites[i].site_num); + }, + onHover: (evt, elements) => { + const target = evt?.native?.target as HTMLElement | null; + if (target) { + target.style.cursor = + onSiteClick && elements?.length ? "pointer" : "default"; + } + }, }} /> diff --git a/client/src/pages/InspectPage.tsx b/client/src/pages/InspectPage.tsx index f0b97d9..7ce425c 100644 --- a/client/src/pages/InspectPage.tsx +++ b/client/src/pages/InspectPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { RefObject } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; import { MoorhenContainer, @@ -26,6 +26,11 @@ export function InspectPage() { const id = Number(projectId); const dispatch = useDispatch(); const [project, setProject] = useState(null); + // Deep-link: `?site=N` (from clicking a site bar on the dashboard) tells the + // drawer to open in Site mode focused on that site. + const [searchParams] = useSearchParams(); + const siteParam = searchParams.get("site"); + const initialSite = siteParam != null ? Number(siteParam) : null; const cootInitialized = useSelector( (s: any) => s.generalStates.cootInitialized @@ -56,11 +61,12 @@ export function InspectPage() { glRef={glRef} commandCentre={commandCentre} cootInitialized={!!cootInitialized} + initialSite={initialSite} /> ), }, }), - [project?.name, id, cootInitialized] + [project?.name, id, cootInitialized, initialSite] ); useEffect(() => { diff --git a/client/src/pages/ProjectDashboard.tsx b/client/src/pages/ProjectDashboard.tsx index d3afa18..f788c38 100644 --- a/client/src/pages/ProjectDashboard.tsx +++ b/client/src/pages/ProjectDashboard.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { Box, Button, @@ -33,6 +33,7 @@ function StatCard({ label, value }: { label: string; value: string | number }) { export function ProjectDashboard() { const { projectId } = useParams(); const id = Number(projectId); + const navigate = useNavigate(); const [project, setProject] = useState(null); const [reports, setReports] = useState([]); const [runs, setRuns] = useState([]); @@ -147,7 +148,13 @@ export function ProjectDashboard() { label={`${s.n_refined} crystals refined`} /> - + + navigate(`/projects/${id}/inspect?site=${siteNum}`) + } + /> {/* PanDDA runs for this project (cloud-triggered). Mirrors the Reports