Add branding splash and simulator cycle timing

This commit is contained in:
masterdev
2026-06-17 13:51:12 +01:00
parent d2ddcb2b20
commit 6d5e11e433
8 changed files with 203 additions and 15 deletions

View File

@@ -331,19 +331,23 @@ def run_simulator_scenario(payload: SimulatorScenarioCreate, db: Session = Depen
elif payload.scenario == "cycle_signal_edge": elif payload.scenario == "cycle_signal_edge":
current_value = machine.cycle_signal_edge != "falling" current_value = machine.cycle_signal_edge != "falling"
previous_value = not current_value previous_value = not current_value
last_cycle_at = None
if machine.last_cycle_at is not None:
last_cycle_at = machine.last_cycle_at.replace(tzinfo=UTC) if machine.last_cycle_at.tzinfo is None else machine.last_cycle_at.astimezone(UTC)
current_event_time = last_cycle_at + timedelta(seconds=cycle_time_sec) if last_cycle_at is not None else now
events.extend( events.extend(
[ [
SimulatorEventCreate( SimulatorEventCreate(
machine_id=machine.code, machine_id=machine.code,
event_type="digital_input_changed", event_type="digital_input_changed",
timestamp=now - timedelta(milliseconds=max(machine.debounce_ms + 100, 500)), timestamp=current_event_time - timedelta(milliseconds=max(machine.debounce_ms + 100, 500)),
input_name="cycle_signal", input_name="cycle_signal",
input_value=previous_value, input_value=previous_value,
), ),
SimulatorEventCreate( SimulatorEventCreate(
machine_id=machine.code, machine_id=machine.code,
event_type="digital_input_changed", event_type="digital_input_changed",
timestamp=now, timestamp=current_event_time,
input_name="cycle_signal", input_name="cycle_signal",
input_value=current_value, input_value=current_value,
cycle_time_sec=cycle_time_sec, cycle_time_sec=cycle_time_sec,

View File

@@ -195,3 +195,23 @@ def test_simulator_debounce_noise_is_ignored(db_session: Session) -> None:
assert result["scenario"] == "debounce_noise" assert result["scenario"] == "debounce_noise"
assert len(result["events"]) == 2 assert len(result["events"]) == 2
assert cycles == [] assert cycles == []
def test_simulator_passive_edge_uses_requested_real_cycle_time(db_session: Session) -> None:
machine = seed_running_machine(db_session)
machine.last_cycle_at = datetime(2026, 6, 14, 8, 0, tzinfo=timezone.utc)
db_session.commit()
result = run_simulator_scenario(
SimulatorScenarioCreate(
machine_id=machine.code,
scenario="cycle_signal_edge",
cycle_time_sec=12,
),
db_session,
)
cycle = db_session.execute(select(Cycle).where(Cycle.machine_id == machine.id)).scalar_one()
assert result["scenario"] == "cycle_signal_edge"
assert len(result["events"]) == 2
assert cycle.cycle_time_sec == 12

BIN
frontend/public/KSS.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -39,6 +39,7 @@ function FieldLabel({ label, error, children }: { label: string; error?: ReactNo
} }
export default function App() { export default function App() {
const [showSplash, setShowSplash] = useState(true);
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null); const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null);
const [orders, setOrders] = useState<ProductionOrder[]>([]); const [orders, setOrders] = useState<ProductionOrder[]>([]);
const [unqualifiedDowntimes, setUnqualifiedDowntimes] = useState<Downtime[]>([]); const [unqualifiedDowntimes, setUnqualifiedDowntimes] = useState<Downtime[]>([]);
@@ -77,6 +78,11 @@ export default function App() {
return language === "fr" ? fr : en; return language === "fr" ? fr : en;
} }
useEffect(() => {
const timer = window.setTimeout(() => setShowSplash(false), 900);
return () => window.clearTimeout(timer);
}, []);
const moduleTabs: Array<{ key: ModuleKey; label: string; description: string }> = [ const moduleTabs: Array<{ key: ModuleKey; label: string; description: string }> = [
{ key: "atelier", label: t("Workshop", "Atelier"), description: t("Machines view", "Vue machines") }, { key: "atelier", label: t("Workshop", "Atelier"), description: t("Machines view", "Vue machines") },
{ key: "machine", label: "Machine", description: t("Cycles and events", "Cycles et evenements") }, { key: "machine", label: "Machine", description: t("Cycles and events", "Cycles et evenements") },
@@ -1118,14 +1124,35 @@ export default function App() {
); );
} }
if (showSplash) {
return (
<div className="splash-screen" aria-label="Plast Track">
<img src="/KSS.png" alt="KSS" />
</div>
);
}
return ( return (
<div className={`app-shell ${isWorkshopMode && activeModule === "atelier" ? "app-shell--workshop" : ""}`}> <div className={`app-shell ${isWorkshopMode && activeModule === "atelier" ? "app-shell--workshop" : ""}`}>
<main className="workspace"> <main className="workspace">
<aside className="demo-notice" role="note">
<strong>{t("Simulation and demonstration only", "Simulation et demonstration uniquement")}</strong>
<span>
{t(
"This web application is for simulation and demonstration. It must not be used as a validated production MES or safety system.",
"Cette application web est destinee a la simulation et a la demonstration. Elle ne doit pas etre utilisee comme MES de production valide ni comme systeme de securite.",
)}
</span>
</aside>
<header className="hero"> <header className="hero">
<div className="brand-lockup">
<img className="brand-lockup__logo" src="/KSS.png" alt="KSS" />
<div> <div>
<p className="eyebrow">Plast Track MVP</p> <p className="eyebrow">Plast Track MVP</p>
<h1>{t("Workshop supervision", "Supervision atelier")}</h1> <h1>{t("Workshop supervision", "Supervision atelier")}</h1>
</div> </div>
</div>
<div className="hero__controls"> <div className="hero__controls">
<label className="language-select"> <label className="language-select">
<span>{t("Language", "Langue")}</span> <span>{t("Language", "Langue")}</span>

View File

@@ -47,6 +47,19 @@ body {
background: var(--bg-base); background: var(--bg-base);
} }
.splash-screen {
display: grid;
min-height: 100vh;
place-items: center;
background: #FFFFFF;
}
.splash-screen img {
width: min(220px, 58vw);
height: auto;
animation: splash-enter 420ms ease both;
}
button, button,
input, input,
select, select,
@@ -71,6 +84,7 @@ button:disabled {
padding: 12px; padding: 12px;
} }
.app-shell--workshop .demo-notice,
.app-shell--workshop .hero, .app-shell--workshop .hero,
.app-shell--workshop .summary-ribbon, .app-shell--workshop .summary-ribbon,
.app-shell--workshop .module-tabs { .app-shell--workshop .module-tabs {
@@ -97,6 +111,30 @@ button:disabled {
align-items: start; align-items: start;
} }
.demo-notice {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 46%, #FFFFFF);
border-radius: 12px;
background: var(--accent-light);
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(10, 47, 90, 0.06), 0 4px 12px rgba(10, 47, 90, 0.04);
}
.demo-notice strong {
flex: 0 0 auto;
font-family: "Space Grotesk", sans-serif;
font-size: var(--text-base);
}
.demo-notice span {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.45;
}
.hero, .hero,
.panel, .panel,
.machine-card, .machine-card,
@@ -115,6 +153,20 @@ button:disabled {
padding: 20px; padding: 20px;
} }
.brand-lockup {
display: flex;
min-width: 0;
align-items: center;
gap: 16px;
}
.brand-lockup__logo {
width: 74px;
max-height: 58px;
flex: 0 0 auto;
object-fit: contain;
}
.hero h1 { .hero h1 {
margin: 0; margin: 0;
font-family: "Space Grotesk", sans-serif; font-family: "Space Grotesk", sans-serif;
@@ -1066,6 +1118,8 @@ tr.is-selected-row td {
} }
.hero, .hero,
.demo-notice,
.brand-lockup,
.hero__controls, .hero__controls,
.machine-card__top, .machine-card__top,
.machine-card__hero, .machine-card__hero,
@@ -1080,6 +1134,10 @@ tr.is-selected-row td {
justify-items: start; justify-items: start;
} }
.brand-lockup__logo {
width: 64px;
}
.panel-actions { .panel-actions {
justify-content: stretch; justify-content: stretch;
} }
@@ -1105,6 +1163,18 @@ tr.is-selected-row td {
} }
} }
@keyframes splash-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,

BIN
simulator-ui/public/KSS.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -120,6 +120,7 @@ function parseInputValue(value: string): boolean | number | string {
} }
function App() { function App() {
const [showSplash, setShowSplash] = useState(true);
const [language, setLanguage] = useState<Language>(() => { const [language, setLanguage] = useState<Language>(() => {
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
return savedLanguage === "en" || savedLanguage === "fr" ? savedLanguage : "fr"; return savedLanguage === "en" || savedLanguage === "fr" ? savedLanguage : "fr";
@@ -141,6 +142,11 @@ function App() {
return value[language]; return value[language];
} }
useEffect(() => {
const timer = window.setTimeout(() => setShowSplash(false), 900);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => { useEffect(() => {
apiRequest<Machine[]>("/api/machines") apiRequest<Machine[]>("/api/machines")
.then((items) => { .then((items) => {
@@ -220,9 +226,19 @@ function App() {
const selectedMachineInfo = machines.find((machine) => machine.code === selectedMachine); const selectedMachineInfo = machines.find((machine) => machine.code === selectedMachine);
const machinePowerLabel = selectedMachineInfo?.powered_on ? t("Powered ON", "Sous tension") : t("Powered OFF", "Hors tension"); const machinePowerLabel = selectedMachineInfo?.powered_on ? t("Powered ON", "Sous tension") : t("Powered OFF", "Hors tension");
if (showSplash) {
return (
<main className="splash-screen" aria-label="Plast Track Simulator">
<img src="/KSS.png" alt="KSS" />
</main>
);
}
return ( return (
<main className="shell"> <main className="shell">
<section className="hero"> <section className="hero">
<div className="brand-lockup">
<img className="brand-lockup__logo" src="/KSS.png" alt="KSS" />
<div> <div>
<p className="eyebrow">{t("Separate test console", "Console de test separee")}</p> <p className="eyebrow">{t("Separate test console", "Console de test separee")}</p>
<h1>{t("Passive Signal Simulator", "Simulateur signaux passifs")}</h1> <h1>{t("Passive Signal Simulator", "Simulateur signaux passifs")}</h1>
@@ -233,6 +249,7 @@ function App() {
)} )}
</p> </p>
</div> </div>
</div>
<div className="hero__side"> <div className="hero__side">
<label className="language-select"> <label className="language-select">
<span>{t("Language", "Langue")}</span> <span>{t("Language", "Langue")}</span>
@@ -285,7 +302,7 @@ function App() {
<div className="numeric-grid"> <div className="numeric-grid">
<label className="field"> <label className="field">
<span>{t("Cycle time sec", "Temps cycle sec")}</span> <span>{t("Real cycle time sec", "Temps de cycle reel sec")}</span>
<input <input
min="1" min="1"
step="0.1" step="0.1"

View File

@@ -22,6 +22,19 @@ body {
min-width: 320px; min-width: 320px;
} }
.splash-screen {
display: grid;
min-height: 100vh;
place-items: center;
background: #ffffff;
}
.splash-screen img {
width: min(220px, 58vw);
height: auto;
animation: splash-enter 420ms ease both;
}
button, button,
input, input,
select { select {
@@ -54,6 +67,21 @@ button:disabled {
max-width: 1280px; max-width: 1280px;
} }
.brand-lockup {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 18px;
}
.brand-lockup__logo {
width: 82px;
max-height: 68px;
flex: 0 0 auto;
object-fit: contain;
margin-top: 4px;
}
.eyebrow { .eyebrow {
margin: 0 0 8px; margin: 0 0 8px;
color: var(--accent); color: var(--accent);
@@ -371,10 +399,20 @@ pre {
} }
.hero, .hero,
.brand-lockup,
.control-grid { .control-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.brand-lockup {
display: grid;
}
.brand-lockup__logo {
width: 72px;
margin-top: 0;
}
.connection-card { .connection-card {
min-width: 0; min-width: 0;
} }
@@ -384,6 +422,18 @@ pre {
} }
} }
@keyframes splash-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 620px) { @media (max-width: 620px) {
.scenario-grid, .scenario-grid,
.numeric-grid { .numeric-grid {