Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/api/clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 23 additions & 0 deletions frontend/api/clients/snapshots/getSnapshots.ts
Original file line number Diff line number Diff line change
@@ -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<Snapshot[]> {
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);
}
}
1 change: 1 addition & 0 deletions frontend/api/clients/snapshots/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { getSnapshots } from "./getSnapshots";
1 change: 1 addition & 0 deletions frontend/api/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export { useFailureBreakdown } from "./useFailureBreakdown";
export { useEpisodeRealtime } from "./useEpisodeRealtime";
export { useRunsTotals } from "./useRunsTotals";
export { useRunningEpisodeRealtime } from "./useRunningEpisodeRealtime";
export { useSnapshots } from "./useSnapshots";
1 change: 1 addition & 0 deletions frontend/api/hooks/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
11 changes: 11 additions & 0 deletions frontend/api/hooks/useSnapshots.ts
Original file line number Diff line number Diff line change
@@ -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),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.shot {
width: 100%;
height: auto;
max-height: 100%;
object-fit: contain;
}
21 changes: 21 additions & 0 deletions frontend/components/molecules/SnapshotView/SnapshotView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Image
className={styles.shot}
src={snapshot.url}
alt={alt}
width={snapshot.width ?? 800}
height={snapshot.height ?? 600}
unoptimized
/>
);
}
1 change: 1 addition & 0 deletions frontend/components/molecules/SnapshotView/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SnapshotView } from "./SnapshotView";
1 change: 1 addition & 0 deletions frontend/components/molecules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export { NavTab } from "./NavTab";
export { ThemeToggle } from "./ThemeToggle";
export { EpisodePicker } from "./EpisodePicker";
export { EmptyState } from "./EmptyState";
export { SnapshotView } from "./SnapshotView";
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
};
Expand Down Expand Up @@ -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 }),
Expand All @@ -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<Snapshot | null>((a, b) => (!a || b.after_seq > a.after_seq ? b : a), null);
return { top: pick("top"), side: pick("side") };
}
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 && (
<Button onClick={() => setShot((v) => !v)}>
{shot ? "Ver esquema" : "Ver captura"}
</Button>
);

return (
<main className={styles.screen}>
{header}
Expand All @@ -34,21 +44,26 @@ export function EpisodeDashboard({ view, header, banner, picker }: Props) {
<div className={styles.stage}>
<Panel
title="Vista cenital" padded
aside={<>
aside={shot ? toggle : <>
<LegendItem swatch="planned" label="hueco" />
<LegendItem swatch="last" label="último" />
<LegendItem swatch="support" label="soporte" />
{toggle}
</>}
>
<PalletTopView placements={view.placements} state={view.last}
size={view.palletSize} />
{shot && view.shots.top
? <SnapshotView snapshot={view.shots.top} alt="Captura cenital del simulador" />
: <PalletTopView placements={view.placements} state={view.last}
size={view.palletSize} />}
</Panel>
<Panel
title="Alzado" padded
aside={<Text variant="caption" tone="faint">{view.sideNote}</Text>}
>
<PalletSideView placements={view.placements} state={view.last}
size={view.palletSize} />
{shot && view.shots.side
? <SnapshotView snapshot={view.shots.side} alt="Captura del alzado del simulador" />
: <PalletSideView placements={view.placements} state={view.last}
size={view.palletSize} />}
</Panel>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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
? {
Expand Down
4 changes: 3 additions & 1 deletion frontend/components/screens/LiveScreen/useLiveScreen.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -75,6 +76,7 @@ export function useLiveScreen() {
states: detail.states.data ?? [],
events: detail.events.data ?? [],
runEpisodes: runEpisodes.data ?? [],
snapshots: snapshots.data ?? [],
stale, mode: "live",
}),
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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",
}),
Expand Down
16 changes: 16 additions & 0 deletions frontend/lib/pallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Placement["actual_pose"]>;
Expand Down
13 changes: 13 additions & 0 deletions frontend/lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,19 @@ export async function fetchLatestEpisode(): Promise<Episode | null> {

/* ── 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 };

/**
Expand Down
Loading