diff --git a/frontend/api/clients/index.ts b/frontend/api/clients/index.ts index a519286..d97f04f 100644 --- a/frontend/api/clients/index.ts +++ b/frontend/api/clients/index.ts @@ -4,3 +4,4 @@ export { getEpisodes, getEpisode, getRunningEpisode } from "./episodes"; export { getPlacements, getPalletStates, getEvents } from "./episodeDetail"; export { getFailureBreakdown } from "./failures"; export { getRunsCount, getEpisodesCount } from "./totals"; +export * from "./snapshots"; diff --git a/frontend/api/clients/snapshots/getSnapshots.ts b/frontend/api/clients/snapshots/getSnapshots.ts new file mode 100644 index 0000000..efc4821 --- /dev/null +++ b/frontend/api/clients/snapshots/getSnapshots.ts @@ -0,0 +1,23 @@ +import { supabase, type Snapshot } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /snapshots?episode_id=eq.{id}&order=after_seq.asc`: las capturas del simulador. + * + * Son la prueba de que el dibujo no miente: al acabar el episodio se pone la foto real + * al lado del esquema y se ve si coinciden. */ +export async function getSnapshots(episodeId: string): Promise { + const source = "getSnapshots"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("snapshots").select("*").eq("episode_id", episodeId) + .order("after_seq", { ascending: true }); + if (error) throw error; + return (data ?? []) as Snapshot[]; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/snapshots/index.ts b/frontend/api/clients/snapshots/index.ts new file mode 100644 index 0000000..19af427 --- /dev/null +++ b/frontend/api/clients/snapshots/index.ts @@ -0,0 +1 @@ +export { getSnapshots } from "./getSnapshots"; diff --git a/frontend/api/hooks/index.ts b/frontend/api/hooks/index.ts index b284dae..00ab9d1 100644 --- a/frontend/api/hooks/index.ts +++ b/frontend/api/hooks/index.ts @@ -12,3 +12,4 @@ export { useFailureBreakdown } from "./useFailureBreakdown"; export { useEpisodeRealtime } from "./useEpisodeRealtime"; export { useRunsTotals } from "./useRunsTotals"; export { useRunningEpisodeRealtime } from "./useRunningEpisodeRealtime"; +export { useSnapshots } from "./useSnapshots"; diff --git a/frontend/api/hooks/queryKeys.ts b/frontend/api/hooks/queryKeys.ts index 87d6d23..4f95ff8 100644 --- a/frontend/api/hooks/queryKeys.ts +++ b/frontend/api/hooks/queryKeys.ts @@ -20,5 +20,6 @@ export const queryKeys = { placements: (id: string) => ["episodes", id, "placements"] as const, states: (id: string) => ["episodes", id, "pallet-states"] as const, events: (id: string) => ["episodes", id, "events"] as const, + snapshots: (id: string) => ["episodes", id, "snapshots"] as const, }, }; diff --git a/frontend/api/hooks/useSnapshots.ts b/frontend/api/hooks/useSnapshots.ts new file mode 100644 index 0000000..064a839 --- /dev/null +++ b/frontend/api/hooks/useSnapshots.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSnapshots } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function useSnapshots(episodeId: string | undefined) { + return useQuery({ + queryKey: queryKeys.episodes.snapshots(episodeId ?? ""), + queryFn: () => getSnapshots(episodeId as string), + enabled: Boolean(episodeId), + }); +} diff --git a/frontend/components/molecules/SnapshotView/SnapshotView.module.css b/frontend/components/molecules/SnapshotView/SnapshotView.module.css new file mode 100644 index 0000000..cdb68fb --- /dev/null +++ b/frontend/components/molecules/SnapshotView/SnapshotView.module.css @@ -0,0 +1,6 @@ +.shot { + width: 100%; + height: auto; + max-height: 100%; + object-fit: contain; +} diff --git a/frontend/components/molecules/SnapshotView/SnapshotView.tsx b/frontend/components/molecules/SnapshotView/SnapshotView.tsx new file mode 100644 index 0000000..18a94a1 --- /dev/null +++ b/frontend/components/molecules/SnapshotView/SnapshotView.tsx @@ -0,0 +1,21 @@ +import Image from "next/image"; +import type { Snapshot } from "@/lib/supabase"; +import styles from "./SnapshotView.module.css"; + +type Props = { snapshot: Snapshot; alt: string }; + +/** La captura que dejó el simulador. Se enseña al lado del esquema para comprobar que + * el dibujo no miente: si la foto y el esquema no coinciden, uno de los dos está mal y + * hasta ahora no había forma de saberlo. */ +export function SnapshotView({ snapshot, alt }: Props) { + return ( + {alt} + ); +} diff --git a/frontend/components/molecules/SnapshotView/index.ts b/frontend/components/molecules/SnapshotView/index.ts new file mode 100644 index 0000000..0f57743 --- /dev/null +++ b/frontend/components/molecules/SnapshotView/index.ts @@ -0,0 +1 @@ +export { SnapshotView } from "./SnapshotView"; diff --git a/frontend/components/molecules/index.ts b/frontend/components/molecules/index.ts index 3072670..1e49825 100644 --- a/frontend/components/molecules/index.ts +++ b/frontend/components/molecules/index.ts @@ -21,3 +21,4 @@ export { NavTab } from "./NavTab"; export { ThemeToggle } from "./ThemeToggle"; export { EpisodePicker } from "./EpisodePicker"; export { EmptyState } from "./EmptyState"; +export { SnapshotView } from "./SnapshotView"; diff --git a/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.helper.ts b/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.helper.ts index 6d3c5f0..bba4f55 100644 --- a/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.helper.ts +++ b/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.helper.ts @@ -1,4 +1,4 @@ -import type { Episode, PalletState, Placement, RunEvent } from "@/lib/supabase"; +import type { Episode, PalletState, Placement, RunEvent, Snapshot } from "@/lib/supabase"; import type { PalletSize } from "@/lib/pallet"; import { failureText, mm, palletSize, seconds, signedMm } from "@/lib/ui"; import { routes } from "@/lib/routes"; @@ -24,6 +24,8 @@ export type EpisodeView = { last: PalletState | null; events: RunEvent[]; palletSize: PalletSize; + /** La última captura de cada vista, si el simulador las subió. */ + shots: { top: Snapshot | null; side: Snapshot | null }; sideNote: string; feedNote: string | undefined; }; @@ -86,8 +88,10 @@ export function buildEpisodeView(input: { runEpisodes: Episode[]; stale: boolean; mode: IdentityMode; + snapshots?: Snapshot[]; }): EpisodeView { const { episode, placements, states, events, runEpisodes, stale, mode } = input; + const shots = latestShots(input.snapshots ?? []); const last = lastPalletState(states); return { identity: buildIdentity({ episode, runEpisodes, events, stale, mode }), @@ -97,7 +101,18 @@ export function buildEpisodeView(input: { // El palé puede ser una maqueta a escala: sin esto se dibuja a 1200x800 // y todas las cotas salen mal por el mismo factor. palletSize: palletSize(episode), + shots, sideNote: sideViewNote(episode, last), feedNote: feedNote(episode), }; } + + +/** La captura más avanzada de cada vista: la del final del episodio, que es la que se + * compara con el esquema. */ +function latestShots(snapshots: Snapshot[]): EpisodeView["shots"] { + const pick = (view: Snapshot["view"]) => + snapshots.filter((s) => s.view === view) + .reduce((a, b) => (!a || b.after_seq > a.after_seq ? b : a), null); + return { top: pick("top"), side: pick("side") }; +} diff --git a/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.tsx b/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.tsx index adf3b70..750aa9b 100644 --- a/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.tsx +++ b/frontend/components/organisms/EpisodeDashboard/EpisodeDashboard.tsx @@ -1,6 +1,6 @@ -import type { ReactNode } from "react"; -import { Text } from "../../atoms"; -import { LegendItem, ResultCard } from "../../molecules"; +import { useState, type ReactNode } from "react"; +import { Button, Text } from "../../atoms"; +import { LegendItem, ResultCard, SnapshotView } from "../../molecules"; import { EventFeed } from "../EventFeed"; import { IdentityBar } from "../IdentityBar"; import { KpiGrid } from "../KpiGrid"; @@ -24,6 +24,16 @@ type Props = { * eventos. Es la pantalla de Live y también la del detalle de una ejecución; cambia de * dónde salen los datos, no cómo se ven. */ export function EpisodeDashboard({ view, header, banner, picker }: Props) { + /* Dibujo o captura. Arranca siempre en el dibujo: es el que tiene las cotas y el + polígono de soporte. La foto está para comprobar que el esquema no miente. */ + const [shot, setShot] = useState(false); + const hasShots = Boolean(view.shots.top || view.shots.side); + const toggle = hasShots && ( + + ); + return (
{header} @@ -34,21 +44,26 @@ export function EpisodeDashboard({ view, header, banner, picker }: Props) {
+ aside={shot ? toggle : <> + {toggle} } > - + {shot && view.shots.top + ? + : } {view.sideNote}} > - + {shot && view.shots.side + ? + : }
diff --git a/frontend/components/organisms/PalletSideView/PalletSideView.helper.ts b/frontend/components/organisms/PalletSideView/PalletSideView.helper.ts index 9f1ddc1..ae2b327 100644 --- a/frontend/components/organisms/PalletSideView/PalletSideView.helper.ts +++ b/frontend/components/organisms/PalletSideView/PalletSideView.helper.ts @@ -1,6 +1,6 @@ import type { PalletState, Placement } from "@/lib/supabase"; import { - PALLET_DECK_M, PALLET_DEFAULT, drawable, lastPlacement, loadHeight, + PALLET_DEFAULT, deckHeight, drawable, lastPlacement, loadHeight, minStackHeight, type PalletSize, } from "@/lib/pallet"; import { mm, stabilityState, type State } from "@/lib/ui"; @@ -32,9 +32,10 @@ export function buildSideView( const [PX] = size; const items = drawable(placements); const top = loadHeight(items); + const deck = deckHeight(size); // Un 25 % de aire sobre la carga para que la cota no toque el borde. - const zMax = Math.max(top * 1.25, 0.3); - const scale = (height - PAD.t - PAD.b) / (PALLET_DECK_M + zMax); + const zMax = Math.max(top * 1.25, minStackHeight(size)); + const scale = (height - PAD.t - PAD.b) / (deck + zMax); const width = PX * scale + PAD.l + PAD.r; const X = (x: number) => PAD.l + (x + PX / 2) * scale; @@ -54,9 +55,9 @@ export function buildSideView( return { width, height, - deck: { x: X(-PX / 2), y: Z(0), width: PX * scale, height: PALLET_DECK_M * scale }, - deckLabel: { x: X(-PX / 2) + 10, y: Z(-PALLET_DECK_M / 2) + 4, - text: `palé ${mm(PALLET_DECK_M, 0)}` }, + deck: { x: X(-PX / 2), y: Z(0), width: PX * scale, height: deck * scale }, + deckLabel: { x: X(-PX / 2) + 10, y: Z(-deck / 2) + 4, + text: `palé ${mm(deck, 0)}` }, packages, cog: state ? { diff --git a/frontend/components/screens/LiveScreen/useLiveScreen.ts b/frontend/components/screens/LiveScreen/useLiveScreen.ts index 9c0e257..a055372 100644 --- a/frontend/components/screens/LiveScreen/useLiveScreen.ts +++ b/frontend/components/screens/LiveScreen/useLiveScreen.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useEpisodeDetail, useEpisodeRealtime, useEpisodes, useRunningEpisode, - useRunningEpisodeRealtime, + useRunningEpisodeRealtime, useSnapshots, } from "@/api/hooks"; import { configured } from "@/lib/supabase"; import { useHold } from "@/hooks/useHold"; @@ -39,6 +39,7 @@ export function useLiveScreen() { const detail = useEpisodeDetail(episode?.id); const { lastReceivedAt } = useEpisodeRealtime(episode?.id); + const snapshots = useSnapshots(episode?.id); const now = useNow(episode ? 1000 : null); // Hasta que llegue algo por Realtime, la referencia es la de cuando se montó la pantalla. @@ -75,6 +76,7 @@ export function useLiveScreen() { states: detail.states.data ?? [], events: detail.events.data ?? [], runEpisodes: runEpisodes.data ?? [], + snapshots: snapshots.data ?? [], stale, mode: "live", }), }; diff --git a/frontend/components/screens/RunDetailScreen/useRunDetailScreen.ts b/frontend/components/screens/RunDetailScreen/useRunDetailScreen.ts index 3af75c7..e49036d 100644 --- a/frontend/components/screens/RunDetailScreen/useRunDetailScreen.ts +++ b/frontend/components/screens/RunDetailScreen/useRunDetailScreen.ts @@ -1,5 +1,7 @@ import { useRouter } from "next/navigation"; -import { useEpisodeDetail, useEpisodes, useRun } from "@/api/hooks"; +import { + useEpisodeDetail, useEpisodes, useRun, useSnapshots, +} from "@/api/hooks"; import { configured } from "@/lib/supabase"; import { routes } from "@/lib/routes"; import { buildEpisodeView } from "../../organisms/EpisodeDashboard"; @@ -15,6 +17,7 @@ export function useRunDetailScreen(runId: string, seed: number | undefined) { const list = episodes.data ?? []; const episode = pickEpisode(list, seed); const detail = useEpisodeDetail(episode?.id); + const snapshots = useSnapshots(episode?.id); const retry = () => { void run.refetch(); @@ -47,6 +50,7 @@ export function useRunDetailScreen(runId: string, seed: number | undefined) { states: detail.states.data ?? [], events: detail.events.data ?? [], runEpisodes: list, + snapshots: snapshots.data ?? [], stale: false, mode: "run", }), diff --git a/frontend/lib/pallet.ts b/frontend/lib/pallet.ts index 65937c5..3394fe0 100644 --- a/frontend/lib/pallet.ts +++ b/frontend/lib/pallet.ts @@ -22,6 +22,22 @@ export const PALLET_DEFAULT: PalletSize = [PALLET_X, PALLET_Y]; /** Altura del palé (tablas y tacos), bajo la superficie donde se apila. */ export const PALLET_DECK_M = 0.144; +/* Las dos medidas de abajo eran absolutas y suponían un europeo. Con una maqueta de + 210 mm eso dibujaba un palé 1.8 veces más alto que TODA la carga, y dejaba el montón + en el 18 % del alzado: parecía que no llegaban ni dimensiones ni posiciones. Se + escalan con el ancho del palé, y para 1.2 m dan exactamente los valores de antes. */ + +/** Altura del palé (tablas y tacos). El europeo son 144 mm sobre 1200 de ancho. */ +export function deckHeight([px]: PalletSize): number { + return PALLET_DECK_M * (px / PALLET_X); +} + +/** Altura mínima del eje vertical, para que un palé casi vacío no se dibuje gigante. + * Un cuarto del ancho: 300 mm en el europeo, 53 en la maqueta. */ +export function minStackHeight([px]: PalletSize): number { + return px * 0.25; +} + /** Una colocación con pose y dimensiones, o sea, dibujable. */ export type Drawable = Placement & { actual_pose: NonNullable; diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts index b66b66d..9ee8aac 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -201,6 +201,19 @@ export async function fetchLatestEpisode(): Promise { /* ── comparabilidad ─────────────────────────────────────────────────────── */ +/** Una captura del simulador. `after_seq` casa con `pallet_states.after_seq`, así que + * la foto y el punto de la traza de CoG son el mismo instante. */ +export type Snapshot = { + id: string; + episode_id: string; + after_seq: number; + view: "top" | "side" | "iso" | "camera"; + url: string; + width: number | null; + height: number | null; + created_at: string; +}; + export type Blocker = { field: string; a: string; b: string; why: string }; /**